gloomberb 0.9.6 → 0.9.8

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 (130) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.ts +48 -0
  4. package/src/api-client/types.ts +45 -0
  5. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  6. package/src/app/runtime/desktop-deeplink.ts +496 -0
  7. package/src/app.tsx +34 -0
  8. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  9. package/src/components/chart/bar-chart-renderer.ts +111 -55
  10. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  11. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  12. package/src/components/chart/stock-chart/footer.ts +68 -13
  13. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  14. package/src/components/command-bar/pane-templates/items.ts +11 -3
  15. package/src/components/command-bar/routes/root/results.ts +1 -1
  16. package/src/components/command-bar/surface/index.test.tsx +52 -0
  17. package/src/components/command-bar/workflow/ops.ts +81 -20
  18. package/src/components/index.ts +1 -0
  19. package/src/components/toggle-list.tsx +29 -73
  20. package/src/components/ui/button.tsx +4 -1
  21. package/src/components/ui/checkbox.tsx +76 -0
  22. package/src/components/ui/choice-dialog.tsx +15 -3
  23. package/src/components/ui/frame.tsx +1 -1
  24. package/src/components/ui/index.ts +2 -0
  25. package/src/components/ui/message-composer.tsx +4 -0
  26. package/src/components/ui/native-select.tsx +4 -2
  27. package/src/market-data/coordinator/events.test.ts +49 -0
  28. package/src/market-data/coordinator/events.ts +25 -4
  29. package/src/market-data/coordinator/index.test.ts +223 -0
  30. package/src/market-data/coordinator/index.ts +40 -3
  31. package/src/market-data/coordinator/quotes.ts +13 -5
  32. package/src/market-data/hooks.tsx +17 -13
  33. package/src/market-data/market/freshness.ts +58 -1
  34. package/src/market-data/quotes/freshness.test.ts +49 -1
  35. package/src/market-data/quotes/freshness.ts +8 -0
  36. package/src/market-data/quotes/resolution.test.ts +185 -0
  37. package/src/market-data/quotes/resolution.ts +94 -8
  38. package/src/market-data/request-types.test.ts +61 -1
  39. package/src/market-data/request-types.ts +42 -3
  40. package/src/pane-settings.test.ts +1 -0
  41. package/src/plugins/builtin/account-management/footer.ts +2 -5
  42. package/src/plugins/builtin/account-management/form-components.tsx +260 -24
  43. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  44. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  45. package/src/plugins/builtin/account-management/model.ts +263 -3
  46. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  47. package/src/plugins/builtin/chat/chat.test.tsx +125 -0
  48. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  49. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  50. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  51. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  52. package/src/plugins/builtin/chat/content/index.tsx +154 -16
  53. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  54. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  55. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  56. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  58. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  59. package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
  60. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  61. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  62. package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
  63. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  64. package/src/plugins/builtin/chat/test-harness.tsx +21 -11
  65. package/src/plugins/builtin/cloud/plugin.tsx +7 -0
  66. package/src/plugins/builtin/correlation/index.tsx +4 -1
  67. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  68. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  69. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  70. package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
  71. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  72. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  73. package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
  74. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
  75. package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
  76. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
  77. package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
  78. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
  79. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  80. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  81. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  82. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  83. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  84. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  85. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  86. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  87. package/src/plugins/registry/context.ts +15 -0
  88. package/src/plugins/registry/index.ts +31 -0
  89. package/src/renderers/electrobun/bun/index.ts +42 -0
  90. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  91. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  92. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  93. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  94. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  95. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  96. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  97. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  98. package/src/renderers/electrobun/view/main.tsx +3 -0
  99. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  100. package/src/sources/provider-router/batches.ts +11 -3
  101. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  102. package/src/sources/provider-router/financial-routes.ts +55 -5
  103. package/src/sources/provider-router/financials.test.ts +49 -0
  104. package/src/sources/provider-router/financials.ts +40 -3
  105. package/src/sources/provider-router/index.test.ts +147 -0
  106. package/src/sources/provider-router/primary.ts +17 -3
  107. package/src/sources/provider-router/streaming.ts +6 -1
  108. package/src/sources/sec-edgar.test.ts +42 -0
  109. package/src/sources/sec-edgar.ts +1 -2
  110. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  111. package/src/sources/yahoo-finance/mappers.ts +2 -1
  112. package/src/sources/yahoo-finance/requests.ts +2 -1
  113. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  114. package/src/sources/yahoo-finance.ts +6 -2
  115. package/src/state/hooks/quote-streaming.test.ts +14 -1
  116. package/src/state/hooks/quote-streaming.ts +5 -4
  117. package/src/sync/controller.ts +296 -0
  118. package/src/sync/core-contributors.test.ts +251 -0
  119. package/src/sync/core-contributors.ts +400 -0
  120. package/src/sync/profile-analytics.ts +39 -0
  121. package/src/sync/react.ts +53 -0
  122. package/src/sync/types.ts +77 -0
  123. package/src/types/config.ts +1 -0
  124. package/src/types/desktop-deeplink.ts +7 -0
  125. package/src/types/plugin.ts +3 -0
  126. package/src/ui/host.tsx +14 -1
  127. package/src/ui/index.tsx +1 -0
  128. package/src/utils/exchanges.test.ts +11 -1
  129. package/src/utils/exchanges.ts +5 -1
  130. package/src/version.ts +1 -1
@@ -312,6 +312,61 @@ describe("AssetDataRouter", () => {
312
312
  expect(quote.price).toBe(123.45);
313
313
  });
314
314
 
315
+ test("reconciles broker quote price with provider day reference fields", async () => {
316
+ const router = new AssetDataRouter({
317
+ ...fallbackProvider,
318
+ id: "yahoo",
319
+ async getQuote() {
320
+ return {
321
+ symbol: "VICR",
322
+ providerId: "yahoo",
323
+ price: 299.74,
324
+ currency: "USD",
325
+ previousClose: 282.95,
326
+ change: 16.79,
327
+ changePercent: 5.93,
328
+ lastUpdated: Date.now(),
329
+ };
330
+ },
331
+ });
332
+ const broker: BrokerAdapter = {
333
+ id: "ibkr",
334
+ name: "IBKR",
335
+ configSchema: [],
336
+ async validate() {
337
+ return true;
338
+ },
339
+ async importPositions() {
340
+ return [];
341
+ },
342
+ async getQuote() {
343
+ return {
344
+ symbol: "VICR",
345
+ providerId: "ibkr",
346
+ price: 299.8,
347
+ currency: "USD",
348
+ previousClose: 380.1,
349
+ change: -80.3,
350
+ changePercent: -21.12,
351
+ lastUpdated: Date.now(),
352
+ dataSource: "live",
353
+ };
354
+ },
355
+ };
356
+
357
+ attachTestRegistry(router, { brokers: [["ibkr", broker]] });
358
+ setBrokerInstances(router, [brokerInstance()]);
359
+
360
+ const quote = await router.getQuote("VICR", "NASDAQ", { brokerId: "ibkr", brokerInstanceId: "ibkr-work" });
361
+ expect(quote.providerId).toBe("ibkr");
362
+ expect(quote.price).toBe(299.8);
363
+ expect(quote.previousClose).toBe(282.95);
364
+ expect(quote.change).toBeCloseTo(16.85, 5);
365
+ expect(quote.changePercent).toBeCloseTo(5.955, 3);
366
+ expect(quote.provenance?.price?.providerId).toBe("ibkr");
367
+ expect(quote.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
368
+ });
369
+
315
370
  test("prefers broker options chains for broker-context targets", async () => {
316
371
  const chain = {
317
372
  underlyingSymbol: "AAPL",
@@ -820,6 +875,48 @@ describe("AssetDataRouter", () => {
820
875
  unsubscribe();
821
876
  });
822
877
 
878
+ test("drops empty zero provider stream quotes", () => {
879
+ const providerTargets: QuoteSubscriptionTarget[] = [];
880
+ const seenQuotes: Array<{ symbol: string; price: number }> = [];
881
+ const streamingProvider: DataProvider = {
882
+ ...fallbackProvider,
883
+ id: "cloud",
884
+ name: "Cloud",
885
+ subscribeQuotes(targets, onQuote) {
886
+ providerTargets.push(...targets);
887
+ onQuote(targets[0]!, {
888
+ symbol: "HEXA B",
889
+ price: 0,
890
+ currency: "SEK",
891
+ change: 0,
892
+ changePercent: 0,
893
+ lastUpdated: Date.now(),
894
+ listingExchangeName: "SFB",
895
+ dataSource: "delayed",
896
+ });
897
+ return () => {};
898
+ },
899
+ };
900
+
901
+ const router = new AssetDataRouter(fallbackProvider, [streamingProvider]);
902
+ const unsubscribe = router.subscribeQuotes([{
903
+ symbol: "HEXA B",
904
+ exchange: "SFB",
905
+ route: "provider",
906
+ }], (_target, quote) => {
907
+ seenQuotes.push({ symbol: quote.symbol, price: quote.price });
908
+ });
909
+
910
+ expect(providerTargets).toEqual([{
911
+ symbol: "HEXA B",
912
+ exchange: "SFB",
913
+ route: "provider",
914
+ }]);
915
+ expect(seenQuotes).toEqual([]);
916
+
917
+ unsubscribe();
918
+ });
919
+
823
920
  test("returns quote-only financials for option tickers when snapshots are unavailable", async () => {
824
921
  const router = new AssetDataRouter({
825
922
  ...fallbackProvider,
@@ -1308,6 +1405,56 @@ describe("AssetDataRouter", () => {
1308
1405
  expect(yahooCalls).toBe(1);
1309
1406
  });
1310
1407
 
1408
+ test("fills unusable preferred provider financial quotes from a later provider", async () => {
1409
+ const cloudProvider: DataProvider = {
1410
+ ...fallbackProvider,
1411
+ id: "cloud",
1412
+ name: "Cloud",
1413
+ priority: 100,
1414
+ async getTickerFinancials() {
1415
+ return makeFinancials({
1416
+ profile: { sector: "Industrials" },
1417
+ quote: makeQuote({
1418
+ symbol: "HY9H",
1419
+ price: 1295,
1420
+ change: -95,
1421
+ changePercent: -6.83,
1422
+ lastUpdated: Date.now() - 20 * 60_000,
1423
+ listingExchangeName: "FWB2",
1424
+ marketState: "REGULAR",
1425
+ }),
1426
+ });
1427
+ },
1428
+ };
1429
+ const yahooProvider: DataProvider = {
1430
+ ...fallbackProvider,
1431
+ id: "yahoo",
1432
+ name: "Yahoo",
1433
+ priority: 1000,
1434
+ async getTickerFinancials() {
1435
+ return makeFinancials({
1436
+ quote: makeQuote({
1437
+ symbol: "HY9H.F",
1438
+ price: 1305,
1439
+ change: -85,
1440
+ changePercent: -6.12,
1441
+ lastUpdated: Date.now(),
1442
+ listingExchangeName: "FWB2",
1443
+ marketState: "REGULAR",
1444
+ }),
1445
+ });
1446
+ },
1447
+ };
1448
+
1449
+ const router = new AssetDataRouter(yahooProvider, [cloudProvider]);
1450
+ const merged = await router.getTickerFinancials("HY9H", "FWB2", { cacheMode: "refresh" });
1451
+
1452
+ expect(merged.profile?.sector).toBe("Industrials");
1453
+ expect(merged.quote?.symbol).toBe("HY9H.F");
1454
+ expect(merged.quote?.price).toBe(1305);
1455
+ expect(merged.quote?.changePercent).toBe(-6.12);
1456
+ });
1457
+
1311
1458
  test("caches the preferred provider financial snapshot without merging fallback snapshots", async () => {
1312
1459
  const dbPath = createTempDbPath("cached-provider-financial-preferred");
1313
1460
  const persistence = new AppPersistence(dbPath);
@@ -1,14 +1,16 @@
1
1
  import type { MarketDataRequestContext } from "../../types/data-provider";
2
2
  import type { Quote, TickerFinancials } from "../../types/financials";
3
3
  import { normalizeTickerFinancialsPriceHistory } from "../../utils/price-history";
4
- import { isQuoteStaleForCurrentSession } from "../../market-data/quotes/freshness";
5
4
  import { resolveTickerFinancialsQuoteState } from "../../market-data/quotes/resolution";
6
5
  import { shouldLogProviderError } from "../provider-errors";
7
6
  import {
7
+ dropUnusableProviderQuote,
8
8
  hasDetailedStatementRows,
9
9
  hasDeepStatementHistory,
10
10
  hasStatementRows,
11
+ isProviderQuoteUsableForCurrentSession,
11
12
  mergeMissingStatementArrays,
13
+ mergeFinancials,
12
14
  } from "./financials";
13
15
  import type { ProviderRouterCoreDeps, SourceResult } from "./route-types";
14
16
 
@@ -61,7 +63,8 @@ export class ProviderRouterPrimaryRoutes {
61
63
  for (const provider of this.options.providersInPriorityOrder()) {
62
64
  try {
63
65
  const rawValue = await provider.getTickerFinancials(ticker, exchange, context);
64
- const value = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(rawValue));
66
+ const resolvedValue = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(rawValue));
67
+ const value = resolvedValue ? dropUnusableProviderQuote(resolvedValue, exchange) : null;
65
68
  if (!value) continue;
66
69
  const sourceKey = this.options.providerSourceKey(provider);
67
70
  const cacheValue = primaryResult
@@ -84,6 +87,17 @@ export class ProviderRouterPrimaryRoutes {
84
87
  if (hasDetailedStatementRows(value) && hasDeepStatementHistory(value)) return primaryResult;
85
88
  continue;
86
89
  }
90
+ if (!primaryResult.value.quote && value.quote) {
91
+ primaryResult = {
92
+ sourceKey: primaryResult.sourceKey,
93
+ value: mergeFinancials(primaryResult.value, {
94
+ annualStatements: [],
95
+ quarterlyStatements: [],
96
+ priceHistory: [],
97
+ quote: value.quote,
98
+ }) ?? primaryResult.value,
99
+ };
100
+ }
87
101
  if (hasStatementRows(value)) {
88
102
  primaryResult = {
89
103
  sourceKey: primaryResult.sourceKey,
@@ -143,7 +157,7 @@ export class ProviderRouterPrimaryRoutes {
143
157
  for (const provider of this.options.providersInPriorityOrder()) {
144
158
  try {
145
159
  const quote = await provider.getQuote(ticker, exchange, context);
146
- if (quote == null || isQuoteStaleForCurrentSession(quote)) continue;
160
+ if (!isProviderQuoteUsableForCurrentSession(quote, exchange)) continue;
147
161
  const sourceKey = this.options.providerSourceKey(provider);
148
162
  this.options.cacheResource(
149
163
  "quote",
@@ -1,6 +1,7 @@
1
1
  import type { DataProvider, MarketDataRequestContext, QuoteSubscriptionTarget } from "../../types/data-provider";
2
2
  import type { Quote } from "../../types/financials";
3
3
  import type { BrokerCandidate } from "./brokers";
4
+ import { isProviderQuoteUsableForCurrentSession } from "./financials";
4
5
 
5
6
  export interface ProviderRouterStreamingDeps {
6
7
  providersInPriorityOrder(): DataProvider[];
@@ -67,7 +68,11 @@ export class ProviderRouterStreamingRoutes {
67
68
  providerId: streamingProvider.id,
68
69
  targetCount: providerTargets.length,
69
70
  });
70
- unsubscribers.push(streamingProvider.subscribeQuotes(providerTargets, onQuote));
71
+ unsubscribers.push(streamingProvider.subscribeQuotes(providerTargets, (target, quote) => {
72
+ if (isProviderQuoteUsableForCurrentSession(quote, target.exchange)) {
73
+ onQuote(target, quote);
74
+ }
75
+ }));
71
76
  }
72
77
 
73
78
  if (unsubscribers.length === 0) {
@@ -200,6 +200,47 @@ describe("parseCompanyFactsFinancialStatements", () => {
200
200
  },
201
201
  ]);
202
202
  });
203
+
204
+ test("merges statement values from all configured tags for a field", () => {
205
+ const fact = (tag: string, unit: string, rows: unknown[]) => ({
206
+ [tag]: {
207
+ units: {
208
+ [unit]: rows,
209
+ },
210
+ },
211
+ });
212
+ const quarterly = (end: string, val: number, frame: string) => ({
213
+ start: `${end.slice(0, 4)}-01-01`,
214
+ end,
215
+ val,
216
+ filed: end,
217
+ form: "10-Q",
218
+ fp: "Q1",
219
+ frame,
220
+ });
221
+
222
+ const statements = parseCompanyFactsFinancialStatements({
223
+ facts: {
224
+ "us-gaap": {
225
+ ...fact("RevenueFromContractWithCustomerExcludingAssessedTax", "USD", [
226
+ quarterly("2026-03-31", 200, "CY2026Q1"),
227
+ ]),
228
+ ...fact("Revenues", "USD", [
229
+ quarterly("2025-03-31", 100, "CY2025Q1"),
230
+ ]),
231
+ ...fact("GrossProfit", "USD", [
232
+ quarterly("2025-03-31", 60, "CY2025Q1"),
233
+ quarterly("2026-03-31", 120, "CY2026Q1"),
234
+ ]),
235
+ },
236
+ },
237
+ });
238
+
239
+ expect(statements.quarterlyStatements).toEqual([
240
+ { date: "2025-03-31", totalRevenue: 100, grossProfit: 60 },
241
+ { date: "2026-03-31", totalRevenue: 200, grossProfit: 120 },
242
+ ]);
243
+ });
203
244
  });
204
245
 
205
246
  describe("SecEdgarClient", () => {
@@ -403,6 +444,7 @@ describe("SecEdgarClient", () => {
403
444
 
404
445
  const client = new SecEdgarClient();
405
446
  const content = await client.getFilingContent({
447
+ form: "10-Q",
406
448
  filingUrl: "https://www.sec.gov/Archives/edgar/data/320193/index.htm",
407
449
  primaryDocumentUrl: "https://www.sec.gov/Archives/edgar/data/320193/primary-doc.htm",
408
450
  });
@@ -451,7 +451,7 @@ function setCompanyFactValue(
451
451
  const selectionKey = `${date}:${String(field)}`;
452
452
  if (compareCompanyFactsEntries(entry, selectedFacts.get(selectionKey)) < 0) return;
453
453
  const row = rows.get(date) ?? { date };
454
- (row as Record<string, unknown>)[field] = value;
454
+ (row as unknown as Record<string, unknown>)[field] = value;
455
455
  rows.set(date, row);
456
456
  selectedFacts.set(selectionKey, entry);
457
457
  }
@@ -506,7 +506,6 @@ export function parseCompanyFactsFinancialStatements(payload: unknown): SecCompa
506
506
  if (entries.length === 0) continue;
507
507
  fillCompanyFactsStatementRows(annualRows, annualSelectedFacts, entries, field, "annual");
508
508
  fillCompanyFactsStatementRows(quarterlyRows, quarterlySelectedFacts, entries, field, "quarterly");
509
- break;
510
509
  }
511
510
  }
512
511
 
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { extractExtendedHoursPrices } from "./mappers";
3
+ import type { ChartResult } from "./types";
4
+
5
+ describe("Yahoo mappers", () => {
6
+ test("derives premarket change from the prior regular close", () => {
7
+ const meta: NonNullable<ChartResult["meta"]> = {
8
+ regularMarketPrice: 39.47,
9
+ chartPreviousClose: 29,
10
+ currentTradingPeriod: {
11
+ pre: { start: 100, end: 200 },
12
+ regular: { start: 300, end: 400 },
13
+ post: { start: 500, end: 600 },
14
+ },
15
+ };
16
+
17
+ const result = extractExtendedHoursPrices(
18
+ meta,
19
+ [100, 200],
20
+ [39, 39.47],
21
+ "PRE",
22
+ 38,
23
+ );
24
+
25
+ expect(result.preMarketPrice).toBe(39.47);
26
+ expect(result.preMarketChange).toBeCloseTo(1.47, 8);
27
+ expect(result.preMarketChangePercent).toBeCloseTo(3.8684210526, 8);
28
+ });
29
+ });
@@ -286,13 +286,14 @@ export function extractExtendedHoursPrices(
286
286
  timestamps: number[],
287
287
  closes: (number | null)[],
288
288
  marketState: MarketState,
289
+ regularCloseOverride?: number,
289
290
  ): ExtendedHoursData {
290
291
  const ctp = meta.currentTradingPeriod;
291
292
  if (!ctp || !timestamps.length) return {};
292
293
 
293
294
  const regStart = ctp.regular?.start ?? 0;
294
295
  const regEnd = ctp.regular?.end ?? Infinity;
295
- const regularClose = meta.regularMarketPrice ?? meta.chartPreviousClose;
296
+ const regularClose = regularCloseOverride ?? meta.regularMarketPrice ?? meta.chartPreviousClose;
296
297
 
297
298
  if (marketState === "PRE") {
298
299
  for (let i = timestamps.length - 1; i >= 0; i--) {
@@ -65,6 +65,7 @@ export async function fetchYahooExtendedHoursData(
65
65
  http: YahooHttpClient,
66
66
  symbol: string,
67
67
  meta: NonNullable<ChartResult["meta"]>,
68
+ regularClose?: number,
68
69
  ): Promise<ExtendedHoursData> {
69
70
  const marketState = deriveMarketState(meta);
70
71
  if (marketState !== "PRE" && marketState !== "POST") return {};
@@ -80,7 +81,7 @@ export async function fetchYahooExtendedHoursData(
80
81
  const result = data.chart?.result?.[0];
81
82
  if (!result?.timestamp?.length) return {};
82
83
  const closes = result.indicators?.quote?.[0]?.close || [];
83
- return extractExtendedHoursPrices(meta, result.timestamp, closes, marketState);
84
+ return extractExtendedHoursPrices(meta, result.timestamp, closes, marketState, regularClose);
84
85
  } catch {
85
86
  return {};
86
87
  }
@@ -42,6 +42,7 @@ interface YahooSnapshotLoaders {
42
42
  fetchExtendedHoursData: (
43
43
  symbol: string,
44
44
  meta: NonNullable<ChartResult["meta"]>,
45
+ regularClose?: number,
45
46
  ) => Promise<ExtendedHoursData>;
46
47
  fetchQuoteSupplement: (
47
48
  symbol: string,
@@ -132,7 +133,12 @@ export async function loadYahooTickerFinancials(
132
133
  const changePct = prev ? (change / prev) * 100 : 0;
133
134
 
134
135
  const marketState = deriveMarketState(meta);
135
- const extHours = await loaders.fetchExtendedHoursData(symbol, meta);
136
+ const extendedHoursBase = quoteSupplement.previousClose ?? prev;
137
+ const extHours = await loaders.fetchExtendedHoursData(
138
+ symbol,
139
+ meta,
140
+ extendedHoursBase == null ? undefined : extendedHoursBase * currencyDivisor,
141
+ );
136
142
  if (currencyDivisor !== 1) {
137
143
  normalizeExtendedHoursPrices(extHours, currencyDivisor);
138
144
  }
@@ -207,7 +213,12 @@ export async function loadYahooQuote(
207
213
  const change = prev != null ? price - prev : 0;
208
214
 
209
215
  const marketState = deriveMarketState(meta);
210
- const extHours = await loaders.fetchExtendedHoursData(symbol, meta);
216
+ const extendedHoursBase = quoteSupplement.previousClose ?? prev;
217
+ const extHours = await loaders.fetchExtendedHoursData(
218
+ symbol,
219
+ meta,
220
+ extendedHoursBase == null ? undefined : extendedHoursBase * currencyDivisor,
221
+ );
211
222
  if (currencyDivisor !== 1) {
212
223
  normalizeExtendedHoursPrices(extHours, currencyDivisor);
213
224
  }
@@ -103,8 +103,12 @@ export class YahooFinanceClient implements DataProvider {
103
103
  }
104
104
 
105
105
  /** Fetch extended hours data using 1d intraday chart with pre/post market included */
106
- private async fetchExtendedHoursData(symbol: string, meta: NonNullable<ChartResult["meta"]>) {
107
- return fetchYahooExtendedHoursData(this.http, symbol, meta);
106
+ private async fetchExtendedHoursData(
107
+ symbol: string,
108
+ meta: NonNullable<ChartResult["meta"]>,
109
+ regularClose?: number,
110
+ ) {
111
+ return fetchYahooExtendedHoursData(this.http, symbol, meta, regularClose);
108
112
  }
109
113
 
110
114
  private async fetchTimeseries(symbol: string, types: string[], period1 = "2010-01-01") {
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { buildQuoteStreamSubscriptionKey } from "./quote-streaming";
2
+ import {
3
+ buildQuoteStreamSubscriptionKey,
4
+ normalizeQuoteStreamSubscriptionTarget,
5
+ } from "./quote-streaming";
3
6
 
4
7
  describe("buildQuoteStreamSubscriptionKey", () => {
5
8
  test("includes broker context so identical symbols can stream independently", () => {
@@ -28,4 +31,14 @@ describe("buildQuoteStreamSubscriptionKey", () => {
28
31
  expect(workKey).not.toBe(personalKey);
29
32
  });
30
33
 
34
+ test("keeps saved exchange aliases in stream targets", () => {
35
+ const target = normalizeQuoteStreamSubscriptionTarget({
36
+ symbol: "lpk",
37
+ exchange: "ibis",
38
+ });
39
+
40
+ expect(target?.symbol).toBe("LPK");
41
+ expect(target?.exchange).toBe("IBIS");
42
+ expect(buildQuoteStreamSubscriptionKey(target!)).toContain("LPK|IBIS");
43
+ });
31
44
  });
@@ -2,18 +2,18 @@ import { useEffect } from "react";
2
2
  import { useAppActive } from "../app/activity";
3
3
  import type { QuoteSubscriptionTarget } from "../../types/data-provider";
4
4
  import { debugLog } from "../../utils/debug-log";
5
- import { canonicalExchange, normalizeSymbol } from "../../utils/exchanges";
5
+ import { normalizeSymbol } from "../../utils/exchanges";
6
6
  import { getSharedMarketDataCoordinator } from "../../market-data/coordinator";
7
7
 
8
8
  const quoteStreamLog = debugLog.createLogger("quote-stream");
9
9
 
10
- function normalizeTarget(target: QuoteSubscriptionTarget): QuoteSubscriptionTarget | null {
10
+ export function normalizeQuoteStreamSubscriptionTarget(target: QuoteSubscriptionTarget): QuoteSubscriptionTarget | null {
11
11
  const symbol = normalizeSymbol(target.symbol);
12
12
  if (!symbol) return null;
13
13
  return {
14
14
  ...target,
15
15
  symbol,
16
- exchange: canonicalExchange(target.exchange),
16
+ exchange: target.exchange?.trim().toUpperCase() ?? "",
17
17
  };
18
18
  }
19
19
 
@@ -43,7 +43,7 @@ export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
43
43
 
44
44
  const normalizedEntries = new Map<string, QuoteSubscriptionTarget>();
45
45
  for (const target of targets) {
46
- const normalized = normalizeTarget(target);
46
+ const normalized = normalizeQuoteStreamSubscriptionTarget(target);
47
47
  if (!normalized) continue;
48
48
  const key = buildQuoteStreamSubscriptionKey(normalized);
49
49
  normalizedEntries.set(key, normalized);
@@ -74,6 +74,7 @@ export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
74
74
  instrument: target.context?.instrument ?? null,
75
75
  },
76
76
  priority: {
77
+ route: target.route,
77
78
  surface: target.surface,
78
79
  visible: target.visible,
79
80
  selected: target.selected,