gloomberb 0.7.3 → 0.7.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.
Files changed (80) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +59 -30
  3. package/src/components/chart/chart-renderer.ts +109 -32
  4. package/src/components/chart/chart-types.ts +18 -0
  5. package/src/components/chart/chart.test.ts +152 -5
  6. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  7. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  8. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  9. package/src/components/chart/market-session.ts +187 -0
  10. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  11. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  12. package/src/components/chart/stock-chart.tsx +36 -8
  13. package/src/components/chart/time-axis-label.tsx +56 -2
  14. package/src/components/command-bar/command-bar.test.tsx +1 -1
  15. package/src/components/command-bar/command-bar.tsx +26 -2
  16. package/src/components/command-bar/list-model.ts +8 -4
  17. package/src/components/command-bar/view-model.test.ts +20 -0
  18. package/src/components/command-bar/view-model.ts +15 -3
  19. package/src/components/layout/shell.test.tsx +26 -0
  20. package/src/components/layout/shell.tsx +69 -21
  21. package/src/components/ticker-list-table.tsx +4 -15
  22. package/src/core/state/session-persistence.ts +25 -0
  23. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  24. package/src/market-data/coordinator.test.ts +122 -2
  25. package/src/market-data/coordinator.ts +192 -55
  26. package/src/market-data/hooks.test.tsx +31 -0
  27. package/src/market-data/hooks.tsx +1 -0
  28. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  29. package/src/plugins/builtin/chat-controller.ts +8 -7
  30. package/src/plugins/builtin/chat.test.tsx +55 -0
  31. package/src/plugins/builtin/chat.tsx +20 -6
  32. package/src/plugins/builtin/holders/index.test.ts +30 -1
  33. package/src/plugins/builtin/holders/index.tsx +7 -3
  34. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  35. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  36. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  37. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  38. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  39. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  40. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  41. package/src/plugins/builtin/sectors/index.tsx +30 -9
  42. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  43. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  44. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  45. package/src/plugins/pane-manager.test.ts +31 -0
  46. package/src/plugins/pane-manager.ts +32 -6
  47. package/src/renderers/electrobun/bun/index.ts +83 -3
  48. package/src/renderers/electrobun/shared/protocol.ts +8 -1
  49. package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
  50. package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
  51. package/src/renderers/electrobun/view/main.tsx +38 -24
  52. package/src/renderers/electrobun/view/styles.css +48 -4
  53. package/src/sources/gloomberb-cloud.test.ts +55 -2
  54. package/src/sources/gloomberb-cloud.ts +180 -29
  55. package/src/sources/provider-router.test.ts +567 -10
  56. package/src/sources/provider-router.ts +323 -93
  57. package/src/sources/yahoo-finance.test.ts +37 -0
  58. package/src/sources/yahoo-finance.ts +48 -2
  59. package/src/state/app-bootstrap.test.ts +50 -23
  60. package/src/state/app-bootstrap.ts +26 -10
  61. package/src/state/session-persistence.test.ts +5 -3
  62. package/src/state/use-inline-tickers.ts +3 -0
  63. package/src/state/use-quote-streaming.ts +11 -0
  64. package/src/types/data-provider.ts +5 -1
  65. package/src/types/financials.ts +1 -0
  66. package/src/utils/api-client.test.ts +120 -0
  67. package/src/utils/api-client.ts +66 -19
  68. package/src/utils/exchanges.test.ts +16 -0
  69. package/src/utils/exchanges.ts +33 -0
  70. package/src/utils/options.ts +1 -1
  71. package/src/utils/price-history.test.ts +28 -1
  72. package/src/utils/price-history.ts +16 -0
  73. package/src/utils/quote-freshness.test.ts +150 -0
  74. package/src/utils/quote-freshness.ts +42 -1
  75. package/src/utils/quote-resolution.ts +2 -0
  76. package/src/utils/quote-time.test.ts +34 -0
  77. package/src/utils/quote-time.ts +18 -5
  78. package/src/utils/ticker-search.ts +72 -1
  79. package/src/version.ts +1 -1
  80. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -33,6 +33,43 @@ describe("YahooFinanceClient exchange aliases", () => {
33
33
  expect(history[0]?.close).toBe(200);
34
34
  });
35
35
 
36
+ test("maps IBKR option symbols to Yahoo option quote marks", async () => {
37
+ const provider = new YahooFinanceClient() as any;
38
+ provider.getOptionsChain = async (ticker: string, _exchange: string, expirationDate: number) => ({
39
+ underlyingSymbol: ticker,
40
+ expirationDates: [expirationDate],
41
+ calls: [{
42
+ contractSymbol: "AMD270917C00230000",
43
+ strike: 230,
44
+ currency: "USD",
45
+ lastPrice: 251,
46
+ change: 1.5,
47
+ percentChange: 0.6,
48
+ volume: 10,
49
+ openInterest: 20,
50
+ bid: 250.25,
51
+ ask: 254.5,
52
+ impliedVolatility: 0.4,
53
+ inTheMoney: false,
54
+ expiration: expirationDate,
55
+ lastTradeDate: 1_800_000_000,
56
+ }],
57
+ puts: [],
58
+ });
59
+
60
+ const quote = await provider.getQuote("AMD 270917C00230000");
61
+
62
+ expect(quote).toMatchObject({
63
+ symbol: "AMD 270917C00230000",
64
+ price: 252.375,
65
+ mark: 252.375,
66
+ bid: 250.25,
67
+ ask: 254.5,
68
+ providerId: "yahoo",
69
+ });
70
+ expect(quote.lastUpdated).toBe(1_800_000_000_000);
71
+ });
72
+
36
73
  test("preserves analyst rating price targets from upgrade history", async () => {
37
74
  const provider = new YahooFinanceClient() as any;
38
75
  let requestUrl = "";
@@ -7,6 +7,7 @@ import {
7
7
  type ManualChartResolution,
8
8
  } from "../components/chart/chart-resolution";
9
9
  import type { InstrumentSearchResult } from "../types/instrument";
10
+ import { parseOptionSymbol } from "../utils/options";
10
11
  import { SecEdgarClient } from "./sec-edgar";
11
12
 
12
13
  // Exchange suffix mapping for Yahoo Finance ticker symbols
@@ -755,7 +756,7 @@ export class YahooFinanceClient implements DataProvider {
755
756
  const history: PricePoint[] = [];
756
757
  for (let i = 0; i < result.timestamp.length; i++) {
757
758
  const close = quote.close?.[i];
758
- if (close == null || Number.isNaN(close)) continue;
759
+ if (close == null || !Number.isFinite(close) || close <= 0) continue;
759
760
  history.push({
760
761
  date: new Date((result.timestamp[i]!) * 1000),
761
762
  open: quote.open?.[i] ?? undefined,
@@ -1047,8 +1048,53 @@ export class YahooFinanceClient implements DataProvider {
1047
1048
  };
1048
1049
  }
1049
1050
 
1051
+ private async getOptionQuote(ticker: string, context?: MarketDataRequestContext): Promise<Quote> {
1052
+ const parsed = parseOptionSymbol(ticker);
1053
+ if (!parsed) throw new Error(`Unsupported option symbol ${ticker}`);
1054
+ const chain = await this.getOptionsChain(parsed.underlying, "", parsed.expTs, context);
1055
+ const contracts = parsed.side === "C" ? chain.calls : chain.puts;
1056
+ const contract = contracts.find((candidate) =>
1057
+ Math.abs(candidate.strike - parsed.strike) < 0.001 &&
1058
+ candidate.expiration === parsed.expTs
1059
+ );
1060
+ if (!contract) throw new Error(`No option contract for ${ticker}`);
1061
+
1062
+ const mark = contract.bid > 0 && contract.ask > 0
1063
+ ? (contract.bid + contract.ask) / 2
1064
+ : contract.bid > 0
1065
+ ? contract.bid
1066
+ : contract.ask > 0
1067
+ ? contract.ask
1068
+ : undefined;
1069
+ return {
1070
+ symbol: ticker,
1071
+ providerId: this.id,
1072
+ price: mark ?? contract.lastPrice,
1073
+ currency: contract.currency || "USD",
1074
+ change: contract.change,
1075
+ changePercent: contract.percentChange,
1076
+ volume: contract.volume,
1077
+ bid: contract.bid,
1078
+ ask: contract.ask,
1079
+ mark,
1080
+ name: contract.contractSymbol,
1081
+ lastUpdated: contract.lastTradeDate > 0
1082
+ ? contract.lastTradeDate * 1000
1083
+ : Date.now(),
1084
+ exchangeName: "OPTIONS",
1085
+ fullExchangeName: "OPTIONS",
1086
+ listingExchangeName: "OPTIONS",
1087
+ listingExchangeFullName: "OPTIONS",
1088
+ dataSource: "delayed",
1089
+ };
1090
+ }
1091
+
1050
1092
  /** Fetch just a quote (lighter weight) */
1051
- async getQuote(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise<Quote> {
1093
+ async getQuote(ticker: string, exchange = "", context?: MarketDataRequestContext): Promise<Quote> {
1094
+ if (parseOptionSymbol(ticker)) {
1095
+ return this.getOptionQuote(ticker, context);
1096
+ }
1097
+
1052
1098
  const symbolsToTry = this.getSymbolsToTry(ticker, exchange);
1053
1099
  let lastError: any;
1054
1100
 
@@ -408,8 +408,28 @@ describe("initializeAppState", () => {
408
408
  custom: {},
409
409
  tags: [],
410
410
  });
411
+ const hydrationSymbols = [
412
+ "AAPL",
413
+ ...Array.from({ length: 13 }, (_, index) => `T${String(index + 1).padStart(2, "0")}`),
414
+ ];
415
+ for (const symbol of hydrationSymbols.slice(1)) {
416
+ await tickerRepository.createTicker({
417
+ ticker: symbol,
418
+ exchange: "NASDAQ",
419
+ currency: "USD",
420
+ name: symbol,
421
+ portfolios: [],
422
+ watchlists: ["main"],
423
+ positions: [],
424
+ broker_contracts: [],
425
+ custom: {},
426
+ tags: [],
427
+ });
428
+ }
411
429
 
412
430
  const events: string[] = [];
431
+ const cachedTargetRequests: string[] = [];
432
+ const financialRefreshes: string[] = [];
413
433
 
414
434
  await initializeAppState({
415
435
  config: quoteOnlyConfig,
@@ -417,23 +437,26 @@ describe("initializeAppState", () => {
417
437
  dataProvider: {
418
438
  id: "test-provider",
419
439
  name: "Test Provider",
420
- getCachedFinancialsForTargets: () => new Map([["AAPL", {
421
- annualStatements: [],
422
- quarterlyStatements: [],
423
- priceHistory: [],
424
- quote: {
425
- symbol: "AAPL",
426
- price: 200,
427
- currency: "USD",
428
- change: 1,
429
- changePercent: 0.5,
430
- marketCap: 2_000_000_000,
431
- lastUpdated: Date.now(),
432
- },
433
- fundamentals: {
434
- trailingPE: 25,
435
- },
436
- }]]),
440
+ getCachedFinancialsForTargets: (targets: Array<{ symbol: string }>) => {
441
+ cachedTargetRequests.push(...targets.map((target) => target.symbol));
442
+ return new Map(targets.map((target) => [target.symbol, {
443
+ annualStatements: [],
444
+ quarterlyStatements: [],
445
+ priceHistory: [],
446
+ quote: {
447
+ symbol: target.symbol,
448
+ price: 200,
449
+ currency: "USD",
450
+ change: 1,
451
+ changePercent: 0.5,
452
+ marketCap: 2_000_000_000,
453
+ lastUpdated: Date.now(),
454
+ },
455
+ fundamentals: {
456
+ trailingPE: 25,
457
+ },
458
+ }]));
459
+ },
437
460
  } as any,
438
461
  sessionSnapshot: {
439
462
  paneState: {
@@ -446,11 +469,11 @@ describe("initializeAppState", () => {
446
469
  activePanel: "left",
447
470
  statusBarVisible: true,
448
471
  openPaneIds: ["portfolio-list:main"],
449
- hydrationTargets: [{
450
- symbol: "AAPL",
472
+ hydrationTargets: hydrationSymbols.map((symbol) => ({
473
+ symbol,
451
474
  exchange: "NASDAQ",
452
475
  instrument: null,
453
- }],
476
+ })),
454
477
  exchangeCurrencies: [],
455
478
  savedAt: Date.now(),
456
479
  },
@@ -458,13 +481,17 @@ describe("initializeAppState", () => {
458
481
  primeCachedFinancials: (entries) => {
459
482
  events.push(`prime:${entries.map((entry) => entry.ticker.metadata.ticker).join(",")}`);
460
483
  },
461
- refreshTicker: () => {},
484
+ refreshTicker: (symbol) => { financialRefreshes.push(symbol); },
462
485
  refreshQuote: () => {},
463
486
  autoImportBrokerPositions: async () => {},
464
487
  });
465
488
 
466
- expect(events).toContain("prime:AAPL");
467
- expect(events.indexOf("prime:AAPL")).toBeLessThan(events.indexOf("SET_INITIALIZED"));
489
+ const primeEvent = events.find((event) => event.startsWith("prime:"));
490
+ expect(primeEvent).toContain("AAPL");
491
+ expect(primeEvent).toContain("T13");
492
+ expect(cachedTargetRequests).toContain("T13");
493
+ expect(financialRefreshes).not.toContain("T13");
494
+ expect(events.indexOf(primeEvent!)).toBeLessThan(events.indexOf("SET_INITIALIZED"));
468
495
 
469
496
  persistence.close();
470
497
  });
@@ -216,26 +216,39 @@ function buildCachedFinancialTarget(ticker: TickerRecord): CachedFinancialsTarge
216
216
  async function resolveCachedFinancialPrimeEntries(
217
217
  refreshPlan: RefreshPlanEntry[],
218
218
  sessionSnapshot: AppSessionSnapshot | null | undefined,
219
+ tickerMap: Map<string, TickerRecord>,
219
220
  dataProvider: DataProvider,
220
221
  ): Promise<Array<{ ticker: TickerRecord; financials: TickerFinancials }>> {
221
222
  if (!dataProvider.getCachedFinancialsForTargets) return [];
222
223
 
223
- const sessionTargetsBySymbol = new Map<string, CachedFinancialsTarget>();
224
+ const targetEntriesBySymbol = new Map<string, { ticker: TickerRecord; target: CachedFinancialsTarget }>();
224
225
  for (const target of sessionSnapshot?.hydrationTargets ?? []) {
225
- sessionTargetsBySymbol.set(target.symbol.trim().toUpperCase(), target);
226
+ const symbol = target.symbol.trim().toUpperCase();
227
+ const ticker = tickerMap.get(symbol);
228
+ if (ticker) {
229
+ targetEntriesBySymbol.set(symbol, { ticker, target });
230
+ }
226
231
  }
227
232
 
228
- const financialEntries = refreshPlan.filter((entry) => entry.mode === "financials");
229
- if (financialEntries.length === 0) return [];
233
+ for (const entry of refreshPlan.filter((entry) => entry.mode === "financials")) {
234
+ const symbol = entry.ticker.metadata.ticker.trim().toUpperCase();
235
+ if (!targetEntriesBySymbol.has(symbol)) {
236
+ targetEntriesBySymbol.set(symbol, {
237
+ ticker: entry.ticker,
238
+ target: buildCachedFinancialTarget(entry.ticker),
239
+ });
240
+ }
241
+ }
230
242
 
243
+ const targetEntries = [...targetEntriesBySymbol.values()];
244
+ if (targetEntries.length === 0) return [];
231
245
  const cachedFinancials = await dataProvider.getCachedFinancialsForTargets(
232
- financialEntries.map(({ ticker }) => (
233
- sessionTargetsBySymbol.get(ticker.metadata.ticker.trim().toUpperCase()) ?? buildCachedFinancialTarget(ticker)
234
- )),
246
+ targetEntries.map((entry) => entry.target),
247
+ { includeStaleQuotes: true },
235
248
  );
236
249
  const primedEntries: Array<{ ticker: TickerRecord; financials: TickerFinancials }> = [];
237
250
 
238
- for (const entry of financialEntries) {
251
+ for (const entry of targetEntries) {
239
252
  const cached = cachedFinancials.get(entry.ticker.metadata.ticker.trim().toUpperCase());
240
253
  if (cached) {
241
254
  primedEntries.push({ ticker: entry.ticker, financials: cached });
@@ -347,8 +360,11 @@ export async function initializeAppState({
347
360
  if (primeCachedFinancials) {
348
361
  const cachedPrimeEntries = await measurePerfAsync(
349
362
  "startup.resolve-cached-financial-prime",
350
- () => resolveCachedFinancialPrimeEntries(refreshPlan, sessionSnapshot, dataProvider),
351
- { financialRefreshCount: refreshPlan.filter((entry) => entry.mode === "financials").length },
363
+ () => resolveCachedFinancialPrimeEntries(refreshPlan, sessionSnapshot, tickerMap, dataProvider),
364
+ {
365
+ financialRefreshCount: refreshPlan.filter((entry) => entry.mode === "financials").length,
366
+ sessionHydrationTargetCount: sessionSnapshot?.hydrationTargets.length ?? 0,
367
+ },
352
368
  );
353
369
  if (cachedPrimeEntries.length > 0) {
354
370
  measurePerf("startup.prime-cached-financials", () => {
@@ -24,7 +24,10 @@ function createTicker(symbol: string, exchange = "NASDAQ"): TickerRecord {
24
24
  describe("session persistence", () => {
25
25
  test("builds a working-set snapshot from runtime state", () => {
26
26
  const config = createDefaultConfig("/tmp/gloomberb-test");
27
- const tickers = new Map<string, TickerRecord>([["AAPL", createTicker("AAPL")]]);
27
+ const tickers = new Map<string, TickerRecord>([
28
+ ["AAPL", createTicker("AAPL")],
29
+ ["MSFT", createTicker("MSFT")],
30
+ ]);
28
31
  const financials = new Map<string, TickerFinancials>([["AAPL", {
29
32
  annualStatements: [],
30
33
  quarterlyStatements: [],
@@ -61,8 +64,7 @@ describe("session persistence", () => {
61
64
  });
62
65
 
63
66
  expect(snapshot.focusedPaneId).toBe("ticker-detail:main");
64
- expect(snapshot.hydrationTargets).toHaveLength(1);
65
- expect(snapshot.hydrationTargets[0]?.symbol).toBe("AAPL");
67
+ expect(snapshot.hydrationTargets.map((target) => target.symbol)).toEqual(["AAPL", "MSFT"]);
66
68
  expect(snapshot.exchangeCurrencies).toContain("JPY");
67
69
  expect(snapshot.paneState["portfolio-list:main"]).toEqual({
68
70
  cursorSymbol: "AAPL",
@@ -152,6 +152,9 @@ export function useInlineTickers(texts: readonly string[]): {
152
152
  return {
153
153
  symbol: ticker.metadata.ticker,
154
154
  exchange: ticker.metadata.exchange,
155
+ surface: "inline" as const,
156
+ visible: true,
157
+ weight: 40,
155
158
  context: instrument
156
159
  ? {
157
160
  brokerId: instrument.brokerId,
@@ -22,6 +22,7 @@ export function buildQuoteStreamSubscriptionKey(target: QuoteSubscriptionTarget)
22
22
  ?? target.context?.instrument?.localSymbol
23
23
  ?? target.context?.instrument?.symbol
24
24
  ?? "";
25
+ const weight = Number.isFinite(target.weight) ? String(target.weight) : "";
25
26
  return [
26
27
  target.symbol,
27
28
  target.exchange ?? "",
@@ -29,6 +30,10 @@ export function buildQuoteStreamSubscriptionKey(target: QuoteSubscriptionTarget)
29
30
  target.context?.brokerInstanceId ?? "",
30
31
  contractKey,
31
32
  target.route ?? "auto",
33
+ target.surface ?? "",
34
+ target.visible ? "visible" : "",
35
+ target.selected ? "selected" : "",
36
+ weight,
32
37
  ].join("|");
33
38
  }
34
39
 
@@ -68,6 +73,12 @@ export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
68
73
  brokerInstanceId: target.context?.brokerInstanceId,
69
74
  instrument: target.context?.instrument ?? null,
70
75
  },
76
+ priority: {
77
+ surface: target.surface,
78
+ visible: target.visible,
79
+ selected: target.selected,
80
+ weight: target.weight,
81
+ },
71
82
  })));
72
83
  return () => {
73
84
  quoteStreamLog.info("unsubscribe", {
@@ -90,6 +90,10 @@ export interface QuoteSubscriptionTarget {
90
90
  exchange?: string;
91
91
  context?: MarketDataRequestContext;
92
92
  route?: "auto" | "provider" | "broker";
93
+ surface?: "portfolio" | "watchlist" | "detail" | "monitor" | "inline" | "screener" | "unknown";
94
+ visible?: boolean;
95
+ selected?: boolean;
96
+ weight?: number;
93
97
  }
94
98
 
95
99
  export interface QuoteBatchResult {
@@ -111,7 +115,7 @@ export interface AssetDataProvider {
111
115
  readonly cachePolicy?: CachePolicyMap;
112
116
 
113
117
  canProvide?(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<boolean> | boolean;
114
- getCachedFinancialsForTargets?(targets: CachedFinancialsTarget[], options?: { allowExpired?: boolean }): Map<string, TickerFinancials> | Promise<Map<string, TickerFinancials>>;
118
+ getCachedFinancialsForTargets?(targets: CachedFinancialsTarget[], options?: { allowExpired?: boolean; includeStaleQuotes?: boolean }): Map<string, TickerFinancials> | Promise<Map<string, TickerFinancials>>;
115
119
  getQuotesBatch?(targets: QuoteSubscriptionTarget[], options?: { forceRefresh?: boolean }): Promise<QuoteBatchResult[]>;
116
120
  getTickerFinancialsBatch?(targets: CachedFinancialsTarget[], options?: { forceRefresh?: boolean }): Promise<TickerFinancialsBatchResult[]>;
117
121
  getTickerFinancials(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<TickerFinancials>;
@@ -32,6 +32,7 @@ export interface Quote {
32
32
  volume?: number;
33
33
  name?: string;
34
34
  lastUpdated: number; // timestamp ms
35
+ receivedAt?: number; // local receipt timestamp ms for streamed/display freshness
35
36
  exchangeName?: string;
36
37
  fullExchangeName?: string;
37
38
  listingExchangeName?: string;
@@ -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", () => {