gloomberb 0.7.2 → 0.7.4
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/README.md +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +158 -47
- package/src/components/chart/chart-types.ts +20 -2
- package/src/components/chart/chart.test.ts +179 -13
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +129 -129
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +108 -19
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
- package/src/components/chart/stock-chart.tsx +95 -49
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +535 -384
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +21 -0
- package/src/components/command-bar/view-model.ts +17 -4
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +21 -3
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +15 -66
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/core/state/session-persistence.ts +25 -0
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +200 -56
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +263 -48
- package/src/plugins/builtin/chat.test.tsx +312 -6
- package/src/plugins/builtin/chat.tsx +245 -66
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +1 -3
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +275 -43
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +6 -4
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +29 -13
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/utils/api-client.test.ts +248 -0
- package/src/utils/api-client.ts +178 -26
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
package/src/ui/context-menu.tsx
CHANGED
|
@@ -127,7 +127,7 @@ function selectedTextMenuItems(text: string, registry: PluginRegistry | null, co
|
|
|
127
127
|
contextMenuDivider("selected-text:ticker-divider"),
|
|
128
128
|
{
|
|
129
129
|
id: "selected-text:open-ticker",
|
|
130
|
-
label: "Open
|
|
130
|
+
label: "Open Ticker Detail",
|
|
131
131
|
onSelect: () => registry?.navigateTicker(symbol),
|
|
132
132
|
},
|
|
133
133
|
{
|
|
@@ -224,13 +224,13 @@ export function tickerContextMenuItems({
|
|
|
224
224
|
const items: ContextMenuItem[] = [
|
|
225
225
|
{
|
|
226
226
|
id: "ticker:open",
|
|
227
|
-
label: `Open ${symbol}`,
|
|
227
|
+
label: `Open ${symbol} Detail`,
|
|
228
228
|
onSelect: () => (openTicker ? openTicker(symbol) : registry?.navigateTicker(symbol)),
|
|
229
229
|
},
|
|
230
230
|
{
|
|
231
231
|
id: "ticker:pin-floating",
|
|
232
|
-
label: `
|
|
233
|
-
onSelect: () => registry?.pinTicker(symbol, { floating: true, paneType: "ticker-detail" }),
|
|
232
|
+
label: `Open ${symbol} in New Floating Detail`,
|
|
233
|
+
onSelect: () => registry?.pinTicker(symbol, { floating: true, paneType: "ticker-detail", forceNewPane: true }),
|
|
234
234
|
},
|
|
235
235
|
{
|
|
236
236
|
id: "ticker:copy-symbol",
|
|
@@ -210,6 +210,126 @@ describe("apiClient auth cookies", () => {
|
|
|
210
210
|
|
|
211
211
|
unsubscribe();
|
|
212
212
|
});
|
|
213
|
+
|
|
214
|
+
test("opens an anonymous market websocket and sends quote priority hints", () => {
|
|
215
|
+
const sent: unknown[] = [];
|
|
216
|
+
const sockets: Array<{
|
|
217
|
+
url: string;
|
|
218
|
+
readyState: number;
|
|
219
|
+
onopen: ((event: unknown) => void) | null;
|
|
220
|
+
close: () => void;
|
|
221
|
+
}> = [];
|
|
222
|
+
|
|
223
|
+
class FakeWebSocket {
|
|
224
|
+
static OPEN = 1;
|
|
225
|
+
readyState = 1;
|
|
226
|
+
onopen: ((event: unknown) => void) | null = null;
|
|
227
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
228
|
+
onclose: ((event: unknown) => void) | null = null;
|
|
229
|
+
onerror: ((event: unknown) => void) | null = null;
|
|
230
|
+
|
|
231
|
+
constructor(readonly url: string) {
|
|
232
|
+
sockets.push(this);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
send(payload: string): void {
|
|
236
|
+
sent.push(JSON.parse(payload));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
close(): void {
|
|
240
|
+
this.readyState = 3;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
globalThis.WebSocket = FakeWebSocket as any;
|
|
245
|
+
|
|
246
|
+
const unsubscribe = apiClient.subscribeQuotes([{
|
|
247
|
+
symbol: "AAPL",
|
|
248
|
+
exchange: "NASDAQ",
|
|
249
|
+
surface: "portfolio",
|
|
250
|
+
visible: true,
|
|
251
|
+
selected: true,
|
|
252
|
+
weight: 100,
|
|
253
|
+
}], () => {});
|
|
254
|
+
sockets[0]!.onopen?.({});
|
|
255
|
+
|
|
256
|
+
expect(sockets).toHaveLength(1);
|
|
257
|
+
expect(sockets[0]!.url).toBe("wss://api.gloom.sh/cloud/ws");
|
|
258
|
+
expect(sent).toContainEqual({
|
|
259
|
+
type: "market.subscribe",
|
|
260
|
+
symbols: [{
|
|
261
|
+
symbol: "AAPL",
|
|
262
|
+
exchange: "NASDAQ",
|
|
263
|
+
surface: "portfolio",
|
|
264
|
+
visible: true,
|
|
265
|
+
selected: true,
|
|
266
|
+
weight: 100,
|
|
267
|
+
}],
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
unsubscribe();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("keeps an anonymous market websocket open after auth rejection", () => {
|
|
274
|
+
const seenPrices: number[] = [];
|
|
275
|
+
let closeCalls = 0;
|
|
276
|
+
const sockets: Array<{
|
|
277
|
+
readyState: number;
|
|
278
|
+
onopen: ((event: unknown) => void) | null;
|
|
279
|
+
onmessage: ((event: { data: string }) => void) | null;
|
|
280
|
+
close: () => void;
|
|
281
|
+
}> = [];
|
|
282
|
+
|
|
283
|
+
class FakeWebSocket {
|
|
284
|
+
static OPEN = 1;
|
|
285
|
+
readyState = 1;
|
|
286
|
+
onopen: ((event: unknown) => void) | null = null;
|
|
287
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
288
|
+
onclose: ((event: unknown) => void) | null = null;
|
|
289
|
+
onerror: ((event: unknown) => void) | null = null;
|
|
290
|
+
|
|
291
|
+
constructor(readonly _url: string) {
|
|
292
|
+
sockets.push(this);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
send(): void {}
|
|
296
|
+
|
|
297
|
+
close(): void {
|
|
298
|
+
closeCalls += 1;
|
|
299
|
+
this.readyState = 3;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
globalThis.WebSocket = FakeWebSocket as any;
|
|
304
|
+
|
|
305
|
+
const unsubscribe = apiClient.subscribeQuotes([{ symbol: "AAPL" }], (_target, quote) => {
|
|
306
|
+
seenPrices.push(quote.price);
|
|
307
|
+
});
|
|
308
|
+
sockets[0]!.onopen?.({});
|
|
309
|
+
sockets[0]!.onmessage?.({ data: JSON.stringify({ type: "auth.unverified" }) });
|
|
310
|
+
sockets[0]!.onmessage?.({
|
|
311
|
+
data: JSON.stringify({
|
|
312
|
+
type: "market.quote",
|
|
313
|
+
symbol: "AAPL",
|
|
314
|
+
exchange: "",
|
|
315
|
+
quote: {
|
|
316
|
+
symbol: "AAPL",
|
|
317
|
+
price: 123,
|
|
318
|
+
currency: "USD",
|
|
319
|
+
change: 0,
|
|
320
|
+
changePercent: 0,
|
|
321
|
+
lastUpdated: 1,
|
|
322
|
+
providerId: "gloomberb-cloud",
|
|
323
|
+
dataSource: "live",
|
|
324
|
+
},
|
|
325
|
+
}),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(closeCalls).toBe(0);
|
|
329
|
+
expect(seenPrices).toEqual([123]);
|
|
330
|
+
|
|
331
|
+
unsubscribe();
|
|
332
|
+
});
|
|
213
333
|
});
|
|
214
334
|
|
|
215
335
|
describe("apiClient chat timestamps", () => {
|
|
@@ -282,6 +402,134 @@ describe("apiClient chat timestamps", () => {
|
|
|
282
402
|
expect(seenCreatedAts).toEqual(["2026-04-08T07:28:27.625Z"]);
|
|
283
403
|
channel.close();
|
|
284
404
|
});
|
|
405
|
+
|
|
406
|
+
test("fetches chat state and normalizes pending notification timestamps", async () => {
|
|
407
|
+
let requestedUrl = "";
|
|
408
|
+
globalThis.fetch = mockFetch(async (input: Request | string | URL) => {
|
|
409
|
+
requestedUrl = String(input);
|
|
410
|
+
return createResponse({
|
|
411
|
+
channels: [{ id: "everyone", name: "everyone", created_at: "2026-04-08T07:00:00.000Z" }],
|
|
412
|
+
onlineCount: 2,
|
|
413
|
+
channelStates: [{
|
|
414
|
+
channelId: "everyone",
|
|
415
|
+
notificationsEnabled: true,
|
|
416
|
+
lastReadMessageId: "m1",
|
|
417
|
+
unreadCount: 1,
|
|
418
|
+
}],
|
|
419
|
+
notifications: [{
|
|
420
|
+
id: "n1",
|
|
421
|
+
type: "reply",
|
|
422
|
+
channelId: "everyone",
|
|
423
|
+
messageId: "m2",
|
|
424
|
+
createdAt: "2026-04-08 07:30:00.000",
|
|
425
|
+
message: {
|
|
426
|
+
id: "m2",
|
|
427
|
+
channelId: "everyone",
|
|
428
|
+
content: "reply",
|
|
429
|
+
replyToId: "m1",
|
|
430
|
+
createdAt: "2026-04-08 07:29:00.000",
|
|
431
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
432
|
+
replyTo: { content: "parent", user: { id: "u1", username: "vince" } },
|
|
433
|
+
},
|
|
434
|
+
}],
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const state = await apiClient.getChatState();
|
|
439
|
+
|
|
440
|
+
expect(new URL(requestedUrl).pathname).toBe("/chat/state");
|
|
441
|
+
expect(state.onlineCount).toBe(2);
|
|
442
|
+
expect(state.notifications[0]?.createdAt).toBe("2026-04-08T07:30:00.000Z");
|
|
443
|
+
expect(state.notifications[0]?.message.createdAt).toBe("2026-04-08T07:29:00.000Z");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("updates chat channel state and marks notifications delivered", async () => {
|
|
447
|
+
const requests: Array<{ path: string; method: string; body: unknown }> = [];
|
|
448
|
+
const responses = [
|
|
449
|
+
createResponse({
|
|
450
|
+
channelId: "everyone",
|
|
451
|
+
notificationsEnabled: true,
|
|
452
|
+
lastReadMessageId: "m2",
|
|
453
|
+
unreadCount: 0,
|
|
454
|
+
}),
|
|
455
|
+
createResponse({ delivered: 2 }),
|
|
456
|
+
createResponse({ onlineCount: 4 }),
|
|
457
|
+
];
|
|
458
|
+
globalThis.fetch = mockFetch(async (input: Request | string | URL, init?: RequestInit) => {
|
|
459
|
+
requests.push({
|
|
460
|
+
path: new URL(String(input)).pathname,
|
|
461
|
+
method: init?.method ?? "GET",
|
|
462
|
+
body: init?.body ? JSON.parse(String(init.body)) : null,
|
|
463
|
+
});
|
|
464
|
+
return responses.shift() as Response;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await apiClient.updateChatChannelState("everyone", {
|
|
468
|
+
notificationsEnabled: true,
|
|
469
|
+
readThroughMessageId: "m2",
|
|
470
|
+
});
|
|
471
|
+
await apiClient.markChatNotificationsDelivered(["n1", "n2"]);
|
|
472
|
+
const presence = await apiClient.getChatPresence();
|
|
473
|
+
|
|
474
|
+
expect(requests).toEqual([
|
|
475
|
+
{
|
|
476
|
+
path: "/chat/channels/everyone/state",
|
|
477
|
+
method: "PATCH",
|
|
478
|
+
body: { notificationsEnabled: true, readThroughMessageId: "m2" },
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
path: "/chat/notifications/delivered",
|
|
482
|
+
method: "POST",
|
|
483
|
+
body: { notificationIds: ["n1", "n2"] },
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
path: "/chat/presence",
|
|
487
|
+
method: "GET",
|
|
488
|
+
body: null,
|
|
489
|
+
},
|
|
490
|
+
]);
|
|
491
|
+
expect(presence).toEqual({ onlineCount: 4 });
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("emits websocket chat presence and notification events", async () => {
|
|
495
|
+
const seenPresence: number[] = [];
|
|
496
|
+
const seenNotifications: string[] = [];
|
|
497
|
+
const unsubscribePresence = apiClient.subscribeChatPresence((onlineCount) => {
|
|
498
|
+
seenPresence.push(onlineCount);
|
|
499
|
+
});
|
|
500
|
+
const unsubscribeNotifications = apiClient.subscribeChatNotifications((notification) => {
|
|
501
|
+
seenNotifications.push(`${notification.id}:${notification.message.createdAt}`);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
await (apiClient as any).handleSocketMessage(JSON.stringify({
|
|
505
|
+
type: "chat.presence",
|
|
506
|
+
onlineCount: 5,
|
|
507
|
+
}));
|
|
508
|
+
await (apiClient as any).handleSocketMessage(JSON.stringify({
|
|
509
|
+
type: "chat.notification",
|
|
510
|
+
data: {
|
|
511
|
+
id: "n1",
|
|
512
|
+
type: "reply",
|
|
513
|
+
channelId: "everyone",
|
|
514
|
+
messageId: "m2",
|
|
515
|
+
createdAt: "2026-04-08 07:30:00.000",
|
|
516
|
+
message: {
|
|
517
|
+
id: "m2",
|
|
518
|
+
channelId: "everyone",
|
|
519
|
+
content: "reply",
|
|
520
|
+
replyToId: "m1",
|
|
521
|
+
createdAt: "2026-04-08 07:29:00.000",
|
|
522
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
523
|
+
replyTo: { content: "parent", user: { id: "u1", username: "vince" } },
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
}));
|
|
527
|
+
|
|
528
|
+
expect(seenPresence).toEqual([5]);
|
|
529
|
+
expect(seenNotifications).toEqual(["n1:2026-04-08T07:29:00.000Z"]);
|
|
530
|
+
unsubscribePresence();
|
|
531
|
+
unsubscribeNotifications();
|
|
532
|
+
});
|
|
285
533
|
});
|
|
286
534
|
|
|
287
535
|
describe("apiClient cloud news", () => {
|
package/src/utils/api-client.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
Fundamentals,
|
|
6
6
|
HolderData,
|
|
7
7
|
HolderRecord,
|
|
8
|
+
OptionsChain,
|
|
8
9
|
PricePoint,
|
|
9
10
|
Quote,
|
|
10
11
|
TickerFinancials,
|
|
@@ -48,7 +49,7 @@ export interface ChatMessage {
|
|
|
48
49
|
replyToId: string | null;
|
|
49
50
|
createdAt: string;
|
|
50
51
|
user: { id: string; username: string; displayName: string };
|
|
51
|
-
replyTo?: { content: string; user: { username: string } } | null;
|
|
52
|
+
replyTo?: { content: string; user: { id?: string; username: string } } | null;
|
|
52
53
|
clientStatus?: "sending" | "failed";
|
|
53
54
|
clientError?: string | null;
|
|
54
55
|
}
|
|
@@ -59,6 +60,29 @@ export interface ChatChannel {
|
|
|
59
60
|
created_at: string;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
export interface ChatChannelState {
|
|
64
|
+
channelId: string;
|
|
65
|
+
notificationsEnabled: boolean;
|
|
66
|
+
lastReadMessageId: string | null;
|
|
67
|
+
unreadCount: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ChatNotification {
|
|
71
|
+
id: string;
|
|
72
|
+
type: "reply" | "mention" | "channel";
|
|
73
|
+
channelId: string;
|
|
74
|
+
messageId: string;
|
|
75
|
+
createdAt: string;
|
|
76
|
+
message: ChatMessage;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ChatStateResponse {
|
|
80
|
+
channels: ChatChannel[];
|
|
81
|
+
onlineCount: number;
|
|
82
|
+
channelStates: ChatChannelState[];
|
|
83
|
+
notifications: ChatNotification[];
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
export interface AuthUser {
|
|
63
87
|
id: string;
|
|
64
88
|
name: string;
|
|
@@ -77,6 +101,10 @@ export interface CloudQuotePayload extends Quote {
|
|
|
77
101
|
dataSource: "live" | "delayed";
|
|
78
102
|
}
|
|
79
103
|
|
|
104
|
+
export interface CloudOptionsChainPayload extends OptionsChain {
|
|
105
|
+
providerId: "gloomberb-cloud";
|
|
106
|
+
}
|
|
107
|
+
|
|
80
108
|
export interface CloudCompanyProfile extends CompanyProfile {}
|
|
81
109
|
|
|
82
110
|
export interface CloudFundamentals extends Fundamentals {}
|
|
@@ -294,10 +322,19 @@ export interface CloudMarketResponse<T> {
|
|
|
294
322
|
reasonCode?: string;
|
|
295
323
|
asOf?: string;
|
|
296
324
|
staleAt?: string;
|
|
325
|
+
stale?: boolean;
|
|
326
|
+
currency?: string;
|
|
297
327
|
providerMeta?: {
|
|
328
|
+
provider?: string;
|
|
298
329
|
upstream?: string;
|
|
330
|
+
status?: CloudMarketStatus;
|
|
331
|
+
reasonCode?: string;
|
|
299
332
|
normalizedSymbol?: string;
|
|
300
333
|
normalizedExchange?: string;
|
|
334
|
+
stale?: boolean;
|
|
335
|
+
fallbackReason?: string;
|
|
336
|
+
requestedResolution?: string;
|
|
337
|
+
servedResolution?: string;
|
|
301
338
|
latencyMs?: number;
|
|
302
339
|
range?: string;
|
|
303
340
|
granularity?: string;
|
|
@@ -333,6 +370,10 @@ export interface CloudVerificationResponse {
|
|
|
333
370
|
export interface QuoteStreamTarget {
|
|
334
371
|
symbol: string;
|
|
335
372
|
exchange?: string;
|
|
373
|
+
surface?: "portfolio" | "watchlist" | "detail" | "monitor" | "inline" | "screener" | "unknown";
|
|
374
|
+
visible?: boolean;
|
|
375
|
+
selected?: boolean;
|
|
376
|
+
weight?: number;
|
|
336
377
|
}
|
|
337
378
|
|
|
338
379
|
function marketKey(symbol: string, exchange?: string): string {
|
|
@@ -352,6 +393,21 @@ function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
|
352
393
|
return messages.map((message) => normalizeChatMessage(message));
|
|
353
394
|
}
|
|
354
395
|
|
|
396
|
+
function normalizeChatNotification(notification: ChatNotification): ChatNotification {
|
|
397
|
+
return {
|
|
398
|
+
...notification,
|
|
399
|
+
createdAt: normalizeTimestamp(notification.createdAt),
|
|
400
|
+
message: normalizeChatMessage(notification.message),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function normalizeChatState(response: ChatStateResponse): ChatStateResponse {
|
|
405
|
+
return {
|
|
406
|
+
...response,
|
|
407
|
+
notifications: response.notifications.map(normalizeChatNotification),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
355
411
|
function normalizeTweet(tweet: CloudTweetPayload): CloudTweetPayload {
|
|
356
412
|
return {
|
|
357
413
|
...tweet,
|
|
@@ -370,6 +426,8 @@ function normalizeTweetSearchResponse(response: CloudTweetSearchResponse): Cloud
|
|
|
370
426
|
}
|
|
371
427
|
|
|
372
428
|
type ChannelListener = (message: ChatMessage) => void;
|
|
429
|
+
type ChatNotificationListener = (notification: ChatNotification) => void;
|
|
430
|
+
type ChatPresenceListener = (onlineCount: number) => void;
|
|
373
431
|
type QuoteListener = (target: QuoteStreamTarget, quote: CloudQuotePayload) => void;
|
|
374
432
|
type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number];
|
|
375
433
|
|
|
@@ -408,6 +466,8 @@ class GloomApiClient {
|
|
|
408
466
|
private reconnectDelayMs = 1000;
|
|
409
467
|
|
|
410
468
|
private readonly channelListeners = new Map<string, Set<ChannelListener>>();
|
|
469
|
+
private readonly chatNotificationListeners = new Set<ChatNotificationListener>();
|
|
470
|
+
private readonly chatPresenceListeners = new Set<ChatPresenceListener>();
|
|
411
471
|
private readonly quoteListeners = new Map<string, Set<QuoteListener>>();
|
|
412
472
|
private readonly quoteTargets = new Map<string, QuoteStreamTarget>();
|
|
413
473
|
private readonly pendingQuoteSubscribes = new Map<string, QuoteStreamTarget>();
|
|
@@ -560,18 +620,20 @@ class GloomApiClient {
|
|
|
560
620
|
}
|
|
561
621
|
|
|
562
622
|
private shouldKeepSocketOpen(): boolean {
|
|
623
|
+
if (this.quoteTargets.size > 0) return true;
|
|
563
624
|
return !!this.getSocketAuthToken()
|
|
564
625
|
&& !!this.currentUser?.emailVerified
|
|
565
|
-
&&
|
|
626
|
+
&& this.channelListeners.size > 0;
|
|
566
627
|
}
|
|
567
628
|
|
|
568
629
|
private ensureSocket(): void {
|
|
569
630
|
if (!this.shouldKeepSocketOpen() || this.ws || this.reconnectTimer) return;
|
|
570
631
|
|
|
571
632
|
const socketToken = this.getSocketAuthToken();
|
|
572
|
-
if (!socketToken) return;
|
|
573
633
|
const usingWebSocketToken = !!this.websocketToken;
|
|
574
|
-
const url =
|
|
634
|
+
const url = socketToken
|
|
635
|
+
? `${this.getWebSocketBaseUrl()}/cloud/ws?token=${encodeURIComponent(socketToken)}`
|
|
636
|
+
: `${this.getWebSocketBaseUrl()}/cloud/ws`;
|
|
575
637
|
cloudApiLog.info("open websocket", {
|
|
576
638
|
hasToken: !!socketToken,
|
|
577
639
|
tokenSource: usingWebSocketToken ? "websocket" : "session",
|
|
@@ -650,7 +712,12 @@ class GloomApiClient {
|
|
|
650
712
|
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
651
713
|
if (payload && typeof payload === "object" && "type" in (payload as Record<string, unknown>)) {
|
|
652
714
|
const type = (payload as Record<string, unknown>).type;
|
|
653
|
-
if (
|
|
715
|
+
if (
|
|
716
|
+
type === "market.subscribe"
|
|
717
|
+
|| type === "market.unsubscribe"
|
|
718
|
+
|| type === "chat.subscribe"
|
|
719
|
+
|| type === "chat.unsubscribe"
|
|
720
|
+
) {
|
|
654
721
|
cloudApiLog.info("send websocket message", payload);
|
|
655
722
|
}
|
|
656
723
|
}
|
|
@@ -667,10 +734,7 @@ class GloomApiClient {
|
|
|
667
734
|
if (this.quoteTargets.size > 0) {
|
|
668
735
|
this.sendSocketMessage({
|
|
669
736
|
type: "market.subscribe",
|
|
670
|
-
symbols: [...this.quoteTargets.values()].map((target) => (
|
|
671
|
-
symbol: target.symbol,
|
|
672
|
-
exchange: target.exchange ?? "",
|
|
673
|
-
})),
|
|
737
|
+
symbols: [...this.quoteTargets.values()].map((target) => this.serializeQuoteStreamTarget(target)),
|
|
674
738
|
});
|
|
675
739
|
}
|
|
676
740
|
}
|
|
@@ -714,23 +778,28 @@ class GloomApiClient {
|
|
|
714
778
|
if (subscribes.length > 0) {
|
|
715
779
|
this.sendSocketMessage({
|
|
716
780
|
type: "market.subscribe",
|
|
717
|
-
symbols: subscribes.map((target) => (
|
|
718
|
-
symbol: target.symbol,
|
|
719
|
-
exchange: target.exchange ?? "",
|
|
720
|
-
})),
|
|
781
|
+
symbols: subscribes.map((target) => this.serializeQuoteStreamTarget(target)),
|
|
721
782
|
});
|
|
722
783
|
}
|
|
723
784
|
if (unsubscribes.length > 0) {
|
|
724
785
|
this.sendSocketMessage({
|
|
725
786
|
type: "market.unsubscribe",
|
|
726
|
-
symbols: unsubscribes.map((target) => (
|
|
727
|
-
symbol: target.symbol,
|
|
728
|
-
exchange: target.exchange ?? "",
|
|
729
|
-
})),
|
|
787
|
+
symbols: unsubscribes.map((target) => this.serializeQuoteStreamTarget(target)),
|
|
730
788
|
});
|
|
731
789
|
}
|
|
732
790
|
}
|
|
733
791
|
|
|
792
|
+
private serializeQuoteStreamTarget(target: QuoteStreamTarget): QuoteStreamTarget {
|
|
793
|
+
return {
|
|
794
|
+
symbol: target.symbol,
|
|
795
|
+
exchange: target.exchange ?? "",
|
|
796
|
+
...(target.surface ? { surface: target.surface } : {}),
|
|
797
|
+
...(target.visible ? { visible: true } : {}),
|
|
798
|
+
...(target.selected ? { selected: true } : {}),
|
|
799
|
+
...(Number.isFinite(target.weight) ? { weight: target.weight } : {}),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
734
803
|
private async handleSocketMessage(raw: string): Promise<void> {
|
|
735
804
|
let parsed: any;
|
|
736
805
|
try {
|
|
@@ -761,6 +830,9 @@ class GloomApiClient {
|
|
|
761
830
|
if (this.currentUser) {
|
|
762
831
|
this.currentUser = { ...this.currentUser, emailVerified: false };
|
|
763
832
|
}
|
|
833
|
+
if (this.quoteTargets.size > 0) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
764
836
|
this.teardownSocket();
|
|
765
837
|
return;
|
|
766
838
|
}
|
|
@@ -773,6 +845,21 @@ class GloomApiClient {
|
|
|
773
845
|
return;
|
|
774
846
|
}
|
|
775
847
|
|
|
848
|
+
if (parsed?.type === "chat.notification" && parsed.data) {
|
|
849
|
+
const notification = normalizeChatNotification(parsed.data as ChatNotification);
|
|
850
|
+
for (const listener of this.chatNotificationListeners) {
|
|
851
|
+
listener(notification);
|
|
852
|
+
}
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (parsed?.type === "chat.presence" && typeof parsed.onlineCount === "number") {
|
|
857
|
+
for (const listener of this.chatPresenceListeners) {
|
|
858
|
+
listener(parsed.onlineCount);
|
|
859
|
+
}
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
776
863
|
if (parsed?.type === "market.quote" && parsed.quote && typeof parsed.symbol === "string") {
|
|
777
864
|
const key = marketKey(parsed.symbol, parsed.exchange);
|
|
778
865
|
const target = this.quoteTargets.get(key) ?? {
|
|
@@ -853,6 +940,32 @@ class GloomApiClient {
|
|
|
853
940
|
return this.request<ChatChannel[]>("/chat/channels");
|
|
854
941
|
}
|
|
855
942
|
|
|
943
|
+
async getChatPresence(): Promise<{ onlineCount: number }> {
|
|
944
|
+
return this.request<{ onlineCount: number }>("/chat/presence");
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async getChatState(): Promise<ChatStateResponse> {
|
|
948
|
+
const state = await this.request<ChatStateResponse>("/chat/state");
|
|
949
|
+
return normalizeChatState(state);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async updateChatChannelState(
|
|
953
|
+
channelId: string,
|
|
954
|
+
body: { notificationsEnabled?: boolean; readThroughMessageId?: string },
|
|
955
|
+
): Promise<ChatChannelState> {
|
|
956
|
+
return this.request<ChatChannelState>(`/chat/channels/${channelId}/state`, {
|
|
957
|
+
method: "PATCH",
|
|
958
|
+
body: JSON.stringify(body),
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async markChatNotificationsDelivered(notificationIds: string[]): Promise<{ delivered: number }> {
|
|
963
|
+
return this.request<{ delivered: number }>("/chat/notifications/delivered", {
|
|
964
|
+
method: "POST",
|
|
965
|
+
body: JSON.stringify({ notificationIds }),
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
856
969
|
async getMessages(
|
|
857
970
|
channelId: string,
|
|
858
971
|
opts?: { after?: string; before?: string; limit?: number },
|
|
@@ -866,10 +979,10 @@ class GloomApiClient {
|
|
|
866
979
|
return normalizeChatMessages(messages);
|
|
867
980
|
}
|
|
868
981
|
|
|
869
|
-
async sendMessage(channelId: string, content: string, replyToId?: string): Promise<ChatMessage> {
|
|
982
|
+
async sendMessage(channelId: string, content: string, replyToId?: string, clientMessageId?: string): Promise<ChatMessage> {
|
|
870
983
|
const message = await this.request<ChatMessage>(`/chat/channels/${channelId}/messages`, {
|
|
871
984
|
method: "POST",
|
|
872
|
-
body: JSON.stringify({ content, replyToId }),
|
|
985
|
+
body: JSON.stringify({ content, replyToId, clientMessageId }),
|
|
873
986
|
});
|
|
874
987
|
return normalizeChatMessage(message);
|
|
875
988
|
}
|
|
@@ -878,7 +991,7 @@ class GloomApiClient {
|
|
|
878
991
|
channelId: string,
|
|
879
992
|
onMessage: (msg: ChatMessage) => void,
|
|
880
993
|
onError?: (err: string) => void,
|
|
881
|
-
): { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void } {
|
|
994
|
+
): { send: (content: string, replyToId?: string, clientMessageId?: string) => Promise<ChatMessage>; close: () => void } {
|
|
882
995
|
if (!channelId) {
|
|
883
996
|
return {
|
|
884
997
|
send: async () => {
|
|
@@ -898,9 +1011,9 @@ class GloomApiClient {
|
|
|
898
1011
|
}
|
|
899
1012
|
|
|
900
1013
|
return {
|
|
901
|
-
send: async (content: string, replyToId?: string) => {
|
|
1014
|
+
send: async (content: string, replyToId?: string, clientMessageId?: string) => {
|
|
902
1015
|
try {
|
|
903
|
-
const message = await this.sendMessage(channelId, content, replyToId);
|
|
1016
|
+
const message = await this.sendMessage(channelId, content, replyToId, clientMessageId);
|
|
904
1017
|
onMessage(message);
|
|
905
1018
|
return message;
|
|
906
1019
|
} catch (error) {
|
|
@@ -914,6 +1027,7 @@ class GloomApiClient {
|
|
|
914
1027
|
current.delete(onMessage);
|
|
915
1028
|
if (current.size === 0) {
|
|
916
1029
|
this.channelListeners.delete(channelId);
|
|
1030
|
+
this.sendSocketMessage({ type: "chat.unsubscribe", channelId });
|
|
917
1031
|
}
|
|
918
1032
|
if (!this.shouldKeepSocketOpen()) {
|
|
919
1033
|
this.teardownSocket();
|
|
@@ -922,6 +1036,20 @@ class GloomApiClient {
|
|
|
922
1036
|
};
|
|
923
1037
|
}
|
|
924
1038
|
|
|
1039
|
+
subscribeChatNotifications(listener: ChatNotificationListener): () => void {
|
|
1040
|
+
this.chatNotificationListeners.add(listener);
|
|
1041
|
+
return () => {
|
|
1042
|
+
this.chatNotificationListeners.delete(listener);
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
subscribeChatPresence(listener: ChatPresenceListener): () => void {
|
|
1047
|
+
this.chatPresenceListeners.add(listener);
|
|
1048
|
+
return () => {
|
|
1049
|
+
this.chatPresenceListeners.delete(listener);
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
925
1053
|
subscribeQuotes(
|
|
926
1054
|
targets: QuoteStreamTarget[],
|
|
927
1055
|
onQuote: (target: QuoteStreamTarget, quote: CloudQuotePayload) => void,
|
|
@@ -933,17 +1061,27 @@ class GloomApiClient {
|
|
|
933
1061
|
const normalized = {
|
|
934
1062
|
symbol: normalizeSymbol(target.symbol),
|
|
935
1063
|
exchange: canonicalExchange(target.exchange),
|
|
1064
|
+
surface: target.surface,
|
|
1065
|
+
visible: target.visible,
|
|
1066
|
+
selected: target.selected,
|
|
1067
|
+
weight: target.weight,
|
|
936
1068
|
} satisfies QuoteStreamTarget;
|
|
937
1069
|
return [marketKey(normalized.symbol, normalized.exchange), normalized] as const;
|
|
938
1070
|
}),
|
|
939
1071
|
).values()];
|
|
940
1072
|
|
|
941
1073
|
const newSubscriptions: QuoteStreamTarget[] = [];
|
|
1074
|
+
const updatedSubscriptions: QuoteStreamTarget[] = [];
|
|
942
1075
|
for (const target of uniqueTargets) {
|
|
943
1076
|
const key = marketKey(target.symbol, target.exchange);
|
|
944
1077
|
const listeners = this.quoteListeners.get(key) ?? new Set<QuoteListener>();
|
|
945
1078
|
if (listeners.size === 0) {
|
|
946
1079
|
newSubscriptions.push(target);
|
|
1080
|
+
} else {
|
|
1081
|
+
const existing = this.quoteTargets.get(key);
|
|
1082
|
+
if (JSON.stringify(this.serializeQuoteStreamTarget(existing ?? target)) !== JSON.stringify(this.serializeQuoteStreamTarget(target))) {
|
|
1083
|
+
updatedSubscriptions.push(target);
|
|
1084
|
+
}
|
|
947
1085
|
}
|
|
948
1086
|
listeners.add(onQuote);
|
|
949
1087
|
this.quoteListeners.set(key, listeners);
|
|
@@ -951,12 +1089,13 @@ class GloomApiClient {
|
|
|
951
1089
|
}
|
|
952
1090
|
|
|
953
1091
|
this.ensureSocket();
|
|
954
|
-
|
|
1092
|
+
const subscriptionsToSend = [...newSubscriptions, ...updatedSubscriptions];
|
|
1093
|
+
if (subscriptionsToSend.length > 0) {
|
|
955
1094
|
cloudApiLog.info("register quote listeners", {
|
|
956
|
-
count:
|
|
957
|
-
symbols:
|
|
1095
|
+
count: subscriptionsToSend.length,
|
|
1096
|
+
symbols: subscriptionsToSend.map((target) => marketKey(target.symbol, target.exchange)),
|
|
958
1097
|
});
|
|
959
|
-
this.queueQuoteSubscribes(
|
|
1098
|
+
this.queueQuoteSubscribes(subscriptionsToSend);
|
|
960
1099
|
}
|
|
961
1100
|
|
|
962
1101
|
return () => {
|
|
@@ -997,6 +1136,8 @@ class GloomApiClient {
|
|
|
997
1136
|
channelTargets: this.channelListeners.size,
|
|
998
1137
|
});
|
|
999
1138
|
this.channelListeners.clear();
|
|
1139
|
+
this.chatNotificationListeners.clear();
|
|
1140
|
+
this.chatPresenceListeners.clear();
|
|
1000
1141
|
this.quoteListeners.clear();
|
|
1001
1142
|
this.quoteTargets.clear();
|
|
1002
1143
|
this.pendingQuoteSubscribes.clear();
|
|
@@ -1034,6 +1175,17 @@ class GloomApiClient {
|
|
|
1034
1175
|
});
|
|
1035
1176
|
}
|
|
1036
1177
|
|
|
1178
|
+
async getCloudOptionsChain(
|
|
1179
|
+
symbol: string,
|
|
1180
|
+
exchange?: string,
|
|
1181
|
+
expirationDate?: number,
|
|
1182
|
+
): Promise<CloudMarketResponse<CloudOptionsChainPayload>> {
|
|
1183
|
+
const params = new URLSearchParams({ symbol });
|
|
1184
|
+
if (exchange) params.set("exchange", exchange);
|
|
1185
|
+
if (expirationDate != null) params.set("expirationDate", String(expirationDate));
|
|
1186
|
+
return this.request<CloudMarketResponse<CloudOptionsChainPayload>>(`/market/options?${params.toString()}`);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1037
1189
|
async getCloudProfile(symbol: string, exchange?: string): Promise<CloudMarketResponse<CloudCompanyProfile>> {
|
|
1038
1190
|
const params = new URLSearchParams({ symbol });
|
|
1039
1191
|
if (exchange) params.set("exchange", exchange);
|