gloomberb 0.9.8 → 0.9.10

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 (181) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/index.test.ts +3 -3
  3. package/src/api-client/index.ts +0 -6
  4. package/src/api-client/request.test.ts +63 -0
  5. package/src/api-client/request.ts +53 -5
  6. package/src/api-client/types.ts +0 -2
  7. package/src/app/pane-runtime/layout-placement.ts +0 -1
  8. package/src/app.tsx +1 -0
  9. package/src/brokers/account-cache.ts +2 -10
  10. package/src/brokers/broker-portfolio-sync.ts +0 -1
  11. package/src/brokers/broker-ticker-sync.ts +20 -16
  12. package/src/brokers/remote-broker-adapter.ts +3 -0
  13. package/src/capabilities/registry.test.ts +25 -0
  14. package/src/capabilities/registry.ts +22 -5
  15. package/src/cli/commands/automation.ts +1 -3
  16. package/src/cli/commands/command-utils.ts +0 -7
  17. package/src/cli/commands/overview.ts +2 -2
  18. package/src/cli/commands/plugins.ts +34 -17
  19. package/src/cli/index.ts +2 -2
  20. package/src/cli.test.ts +0 -157
  21. package/src/components/chart/comparison/renderer.ts +0 -1
  22. package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
  23. package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
  24. package/src/components/chart/core/index.test.ts +0 -24
  25. package/src/components/chart/core/renderer.ts +0 -4
  26. package/src/components/chart/core/resolution.test.ts +0 -17
  27. package/src/components/chart/core/resolution.ts +0 -26
  28. package/src/components/chart/core/scroll.test.ts +0 -6
  29. package/src/components/chart/core/scroll.ts +0 -22
  30. package/src/components/chart/core/viewport.test.ts +2 -23
  31. package/src/components/chart/core/viewport.ts +0 -27
  32. package/src/components/chart/market-session.ts +1 -1
  33. package/src/components/chart/native/surface/hooks.ts +10 -3
  34. package/src/components/chart/stock-chart/data-runtime.ts +0 -1
  35. package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
  36. package/src/components/command-bar/helpers.test.ts +1 -80
  37. package/src/components/command-bar/helpers.ts +1 -36
  38. package/src/components/command-bar/pane-settings.ts +1 -1
  39. package/src/components/command-bar/panel/runtime.ts +0 -2
  40. package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
  41. package/src/components/command-bar/surface/index.test.tsx +0 -16
  42. package/src/components/command-bar/surface/index.tsx +0 -1
  43. package/src/components/feed-data-table/stack-view.test.tsx +36 -130
  44. package/src/components/feed-data-table/stack-view.tsx +0 -1
  45. package/src/components/layout/shell/index.test.tsx +2 -113
  46. package/src/components/layout/shell/index.tsx +0 -1
  47. package/src/components/layout/shell/menu.ts +0 -1
  48. package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
  49. package/src/components/onboarding/onboarding-wizard.tsx +3 -3
  50. package/src/components/onboarding/wizard-keyboard.ts +2 -23
  51. package/src/components/price-selector-dialog.tsx +3 -3
  52. package/src/components/ticker/badge/text.tsx +0 -1
  53. package/src/components/ui/page-stack-view.tsx +1 -1
  54. package/src/components/ui/ui.test.tsx +0 -78
  55. package/src/core/state/app/layout-reducer.ts +0 -1
  56. package/src/data/config/layout.ts +3 -3
  57. package/src/data/resource-store.ts +6 -0
  58. package/src/data/sqlite/cache.test.ts +5 -0
  59. package/src/data/ticker-store.ts +0 -7
  60. package/src/market-data/coordinator/auxiliary.ts +1 -44
  61. package/src/market-data/coordinator/entries.ts +0 -1
  62. package/src/market-data/coordinator/index.test.ts +0 -10
  63. package/src/market-data/coordinator/index.ts +1 -18
  64. package/src/market-data/coordinator/quotes.ts +0 -6
  65. package/src/market-data/coordinator/subscriptions.test.ts +1 -0
  66. package/src/market-data/market/status.ts +19 -1
  67. package/src/market-data/query-store.ts +0 -4
  68. package/src/market-data/quotes/resolution.ts +2 -2
  69. package/src/market-data/request-types.ts +0 -5
  70. package/src/market-data/result-types.ts +0 -11
  71. package/src/market-data/selectors.ts +1 -5
  72. package/src/news/aggregator.ts +5 -1
  73. package/src/pane-settings.test.ts +0 -18
  74. package/src/plugins/builtin/account-management/form-components.tsx +0 -120
  75. package/src/plugins/builtin/account-management/model.ts +3 -27
  76. package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
  77. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  78. package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
  79. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
  80. package/src/plugins/builtin/buildout/format.ts +2 -11
  81. package/src/plugins/builtin/chat/chat.test.tsx +2 -1
  82. package/src/plugins/builtin/chat/content/index.tsx +1 -2
  83. package/src/plugins/builtin/chat/content/transcript.tsx +0 -1
  84. package/src/plugins/builtin/chat/controller/index.ts +0 -10
  85. package/src/plugins/builtin/chat/controller.test.ts +5 -5
  86. package/src/plugins/builtin/chat/sidebar.test.tsx +15 -5
  87. package/src/plugins/builtin/chat/test-harness.tsx +2 -2
  88. package/src/plugins/builtin/cloud/plugin.tsx +1 -1
  89. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
  90. package/src/plugins/builtin/congress-trades/model.ts +2 -7
  91. package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
  92. package/src/plugins/builtin/earnings/index.tsx +0 -9
  93. package/src/plugins/builtin/econ/index.tsx +1 -5
  94. package/src/plugins/builtin/help/index.tsx +2 -2
  95. package/src/plugins/builtin/holders/pane.tsx +0 -5
  96. package/src/plugins/builtin/insider/index.tsx +1 -8
  97. package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
  98. package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
  99. package/src/plugins/builtin/market-movers/index.tsx +0 -2
  100. package/src/plugins/builtin/news/index.tsx +0 -1
  101. package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
  102. package/src/plugins/builtin/options/view.test.tsx +1 -38
  103. package/src/plugins/builtin/plugin-groups.ts +17 -3
  104. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
  105. package/src/plugins/builtin/portfolio-list/index.test.ts +1 -24
  106. package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
  107. package/src/plugins/builtin/sec/filing-documents.ts +0 -11
  108. package/src/plugins/builtin/sec/index.tsx +0 -2
  109. package/src/plugins/builtin/substack/article-detail.tsx +1 -1
  110. package/src/plugins/builtin/thirteenf/api.ts +0 -15
  111. package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
  112. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +1 -1
  113. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
  114. package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
  115. package/src/plugins/builtin/world-indices/index.tsx +0 -4
  116. package/src/plugins/builtin/world-indices/model.ts +0 -7
  117. package/src/plugins/ibkr/account-cache.ts +1 -1
  118. package/src/plugins/ibkr/portfolio-performance.ts +1 -1
  119. package/src/plugins/ibkr/trade/utils.ts +1 -7
  120. package/src/plugins/loader.ts +2 -1
  121. package/src/plugins/prediction-markets/detail.test.tsx +0 -34
  122. package/src/plugins/registry/contributions.ts +22 -11
  123. package/src/plugins/registry/index.test.ts +44 -0
  124. package/src/plugins/registry/index.ts +44 -26
  125. package/src/remote/controller.ts +1 -7
  126. package/src/remote/json-patch.ts +15 -1
  127. package/src/remote/server.ts +16 -6
  128. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
  129. package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
  130. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
  131. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
  132. package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
  133. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  134. package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
  135. package/src/renderers/electrobun/view/host/mouse.ts +0 -11
  136. package/src/renderers/electrobun/view/http-fetch.ts +15 -2
  137. package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
  138. package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
  139. package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
  140. package/src/renderers/opentui/host.tsx +11 -3
  141. package/src/renderers/opentui/input-host.tsx +5 -1
  142. package/src/sources/gloomberb-cloud/index.test.ts +0 -72
  143. package/src/sources/gloomberb-cloud/index.ts +1 -30
  144. package/src/sources/gloomberb-cloud/news.ts +0 -12
  145. package/src/sources/provider-router/financial-routes.ts +25 -3
  146. package/src/sources/provider-router/index.test.ts +167 -0
  147. package/src/sources/provider-router/search.ts +5 -0
  148. package/src/sources/provider-router/supplemental.ts +4 -3
  149. package/src/sources/yahoo-finance/history.ts +5 -1
  150. package/src/sources/yahoo-finance/options.ts +59 -14
  151. package/src/sources/yahoo-finance/quote-summary.ts +4 -1
  152. package/src/sources/yahoo-finance.test.ts +40 -21
  153. package/src/sources/yahoo-finance.ts +13 -8
  154. package/src/state/app/context/index.test.tsx +0 -81
  155. package/src/state/app/context/selector.test.tsx +6 -2
  156. package/src/sync/controller.test.ts +151 -0
  157. package/src/sync/controller.ts +92 -57
  158. package/src/sync/core-contributors.ts +45 -21
  159. package/src/sync/react.ts +6 -8
  160. package/src/sync/types.ts +3 -0
  161. package/src/test-support/data-provider.ts +0 -1
  162. package/src/ui/host.tsx +2 -1
  163. package/src/ui/index.tsx +1 -0
  164. package/src/updater.test.ts +0 -21
  165. package/src/updater.ts +20 -16
  166. package/src/utils/async-deadline.ts +26 -0
  167. package/src/utils/selection-clipboard.ts +2 -2
  168. package/src/version.ts +1 -1
  169. package/src/components/layout/pane/header.test.tsx +0 -39
  170. package/src/plugins/builtin/chat/channels.test.ts +0 -28
  171. package/src/plugins/builtin/chat/index.tsx +0 -4
  172. package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
  173. package/src/plugins/builtin/earnings/index.test.ts +0 -12
  174. package/src/plugins/builtin/help/index.test.tsx +0 -242
  175. package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
  176. package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
  177. package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
  178. package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
  179. package/src/renderers/electrobun/view/host/text.test.ts +0 -22
  180. package/src/renderers/opentui/dialog-host.test.tsx +0 -44
  181. /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
@@ -87,7 +87,7 @@ export function PriceSelectorDialog({
87
87
  const inputRef = useRef<InputRenderable>(null);
88
88
 
89
89
  useEffect(() => {
90
- if (mode === "input") inputRef.current?.focus();
90
+ if (mode === "input") inputRef.current?.focus?.();
91
91
  }, [mode]);
92
92
 
93
93
  useDialogKeyboard((event) => {
@@ -108,7 +108,7 @@ export function PriceSelectorDialog({
108
108
  setMode("input");
109
109
  setIndex(CUSTOM_INDEX);
110
110
  }
111
- } else if (event.name === "return") {
111
+ } else if (event.name === "enter" || event.name === "return") {
112
112
  if (presets[index]) {
113
113
  resolve(String(presets[index].value));
114
114
  }
@@ -124,7 +124,7 @@ export function PriceSelectorDialog({
124
124
  if (event.name === "up" || (event.name === "tab" && presets.length > 0)) {
125
125
  setMode("list");
126
126
  setIndex(presets.length - 1);
127
- } else if (event.name === "return") {
127
+ } else if (event.name === "enter" || event.name === "return") {
128
128
  resolve(customValue.trim());
129
129
  }
130
130
  }
@@ -4,7 +4,6 @@ import { TickerBadge } from "./index";
4
4
  import { ExternalLinkText, openUrl } from "../../ui";
5
5
  import { tokenizeInlineContent } from "../../../utils/inline-content-tokenizer";
6
6
  import type { InlineTickerCatalogEntry } from "../../../state/hooks/inline-tickers";
7
- import { displayWidth } from "../../../utils/format";
8
7
  import { splitLongTextSegmentByDisplayWidth } from "../../../utils/text-wrap";
9
8
  import { blendHex, colors } from "../../../theme/colors";
10
9
 
@@ -88,7 +88,7 @@ export function PageStackView({
88
88
  >
89
89
  <Box height={1} flexDirection="row">
90
90
  <Box
91
- onMouseDown={(event) => {
91
+ onMouseDown={(event: { preventDefault?: () => void; stopPropagation?: () => void }) => {
92
92
  goBack(event);
93
93
  }}
94
94
  backgroundColor={colors.selected}
@@ -1,7 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { act, useEffect, useRef, useState } from "react";
3
3
  import { TestDialogProvider, testRender } from "../../renderers/opentui/test-utils";
4
- import { TextAttributes } from "../../ui";
5
4
  import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
6
5
  import { ChoiceDialog } from "./choice-dialog";
7
6
  import { DataTable, type DataTableColumn } from "./data-table";
@@ -171,37 +170,6 @@ function DataTableHorizontalScrollHarness({
171
170
  );
172
171
  }
173
172
 
174
- function DataTableColumnGapHarness() {
175
- const headerScrollRef = useRef<ScrollBoxRenderable>(null);
176
- const scrollRef = useRef<ScrollBoxRenderable>(null);
177
- const items = [{ id: "row-0", left: "AA", right: "BB" }];
178
-
179
- return (
180
- <DataTableView
181
- rootWidth={8}
182
- rootHeight={4}
183
- selection={{ kind: "none" }}
184
- columns={[
185
- { id: "left", label: "L", width: 2, align: "left" },
186
- { id: "right", label: "R", width: 2, align: "left" },
187
- ]}
188
- items={items}
189
- sortColumnId={null}
190
- sortDirection="asc"
191
- onHeaderClick={() => {}}
192
- headerScrollRef={headerScrollRef}
193
- scrollRef={scrollRef}
194
- getItemKey={(row) => row.id}
195
- renderCell={(row, column) => ({ text: column.id === "left" ? row.left : row.right })}
196
- emptyStateTitle="No rows."
197
- columnGap={0}
198
- horizontalPadding={0}
199
- fillAvailableWidth={false}
200
- showHorizontalScrollbar={false}
201
- />
202
- );
203
- }
204
-
205
173
  function DataTableVirtualizationHarness() {
206
174
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
207
175
  const scrollRef = useRef<ScrollBoxRenderable>(null);
@@ -392,31 +360,6 @@ describe("shared UI kit", () => {
392
360
  expect(lastSelected).toBe("chart");
393
361
  });
394
362
 
395
- test("marks the active bare tab when it has keyboard focus", async () => {
396
- testSetup = await testRender(
397
- <Tabs
398
- tabs={[
399
- { label: "One", value: "one" },
400
- { label: "Two", value: "two" },
401
- ]}
402
- activeValue="one"
403
- onSelect={() => {}}
404
- variant="bare"
405
- compact
406
- focused
407
- />,
408
- { width: 24, height: 2 },
409
- );
410
-
411
- await act(async () => {
412
- await testSetup!.renderOnce();
413
- });
414
-
415
- const activeSpan = testSetup.captureSpans().lines[0]?.spans.find((span) => span.text.includes("One"));
416
- expect(activeSpan).toBeDefined();
417
- expect((activeSpan!.attributes & TextAttributes.UNDERLINE)).toBe(TextAttributes.UNDERLINE);
418
- });
419
-
420
363
  test("selects tabs by clicking their text labels", async () => {
421
364
  let lastSelected = "overview";
422
365
  const selectedValues: string[] = [];
@@ -794,27 +737,6 @@ describe("shared UI kit", () => {
794
737
  expect(tableScrollBoxForTest?.horizontalScrollBar.visible).toBe(false);
795
738
  });
796
739
 
797
- test("renders data table columns without spacer cells when column gap is zero", async () => {
798
- const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
799
- testSetup = await testRender(
800
- <AppContext value={{ state, dispatch: () => {} }}>
801
- <PaneInstanceProvider paneId="portfolio-list:main">
802
- <DataTableColumnGapHarness />
803
- </PaneInstanceProvider>
804
- </AppContext>,
805
- { width: 8, height: 4 },
806
- );
807
-
808
- await act(async () => {
809
- await testSetup!.renderOnce();
810
- await testSetup!.renderOnce();
811
- });
812
-
813
- const frame = testSetup.captureCharFrame();
814
- expect(frame).toContain("AABB");
815
- expect(frame).not.toContain("AA BB");
816
- });
817
-
818
740
  test("hides data table horizontal scrolling when content fits", async () => {
819
741
  const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
820
742
  tableScrollBoxForTest = null;
@@ -2,7 +2,6 @@ import { cloneLayout, createBlankLayout, type SavedLayout } from "../../../types
2
2
  import {
3
3
  clonePaneStateMap,
4
4
  cloneSavedLayout,
5
- getActiveSavedPaneState,
6
5
  historyForIndex,
7
6
  removeHistoryIndex,
8
7
  setHistoryForIndex,
@@ -60,9 +60,9 @@ function sanitizePlacementMemory(value: unknown): PanePlacementMemory | undefine
60
60
  const docked = (() => {
61
61
  const raw = (value as PanePlacementMemory).docked;
62
62
  if (!raw || typeof raw !== "object") return undefined;
63
- const path = Array.isArray((raw as { path?: unknown }).path)
64
- ? (raw as { path?: unknown }).path
65
- ?.filter((segment): segment is 0 | 1 => segment === 0 || segment === 1)
63
+ const rawPath = (raw as { path?: unknown }).path;
64
+ const path = Array.isArray(rawPath)
65
+ ? rawPath.filter((segment): segment is 0 | 1 => segment === 0 || segment === 1)
66
66
  : undefined;
67
67
  const anchorInstanceId = typeof (raw as { anchorInstanceId?: unknown }).anchorInstanceId === "string"
68
68
  ? (raw as { anchorInstanceId: string }).anchorInstanceId
@@ -291,6 +291,12 @@ export class ResourceStore {
291
291
  }
292
292
  records.push(record);
293
293
  }
294
+ if (options.touch !== false) {
295
+ for (const record of records) {
296
+ const touchedAt = this.touch(record);
297
+ if (touchedAt != null) record.lastAccessedAt = touchedAt;
298
+ }
299
+ }
294
300
  return records;
295
301
  }
296
302
 
@@ -35,6 +35,7 @@ describe("AppPersistence", () => {
35
35
  }, {
36
36
  cachePolicy: { staleMs: 60_000, expireMs: 120_000 },
37
37
  schemaVersion: 1,
38
+ fetchedAt: 1,
38
39
  });
39
40
 
40
41
  expect(persistence.resources.get<{ price: number }>({
@@ -43,6 +44,10 @@ describe("AppPersistence", () => {
43
44
  entityKey: "AAPL",
44
45
  sourceKey: "provider:yahoo",
45
46
  }, { allowExpired: true })?.value.price).toBe(123);
47
+ const listed = persistence.resources.list<{ price: number }>({
48
+ namespace: "market", kind: "quote", entityKey: "AAPL",
49
+ }, { allowExpired: true });
50
+ expect(listed[0]?.lastAccessedAt).toBeGreaterThan(1);
46
51
  persistence.close();
47
52
  });
48
53
 
@@ -47,11 +47,4 @@ export class TickerStore {
47
47
  this.db.query("DELETE FROM tickers WHERE symbol = ?").run(symbol);
48
48
  });
49
49
  }
50
-
51
- count(): number {
52
- const row = withSqliteBusyRetry("count tickers", () => (
53
- this.db.query<{ count: number }, []>("SELECT COUNT(*) as count FROM tickers").get()
54
- ));
55
- return row?.count ?? 0;
56
- }
57
50
  }
@@ -1,14 +1,12 @@
1
1
  import type { DataProvider, SecFilingDocument, SecFilingItem } from "../../types/data-provider";
2
- import type { NewsArticle } from "../../news/types";
3
2
  import type { OptionsChain } from "../../types/financials";
4
- import type { NewsRequest, OptionsRequest, SecFilingsRequest } from "../request-types";
3
+ import type { OptionsRequest, SecFilingsRequest } from "../request-types";
5
4
  import type { QueryEntry } from "../result-types";
6
5
  import { QueryStore } from "../query-store";
7
6
  import {
8
7
  ARTICLE_SUMMARY_CACHE_TTL_MS,
9
8
  EXPECTED_EMPTY,
10
9
  FX_CACHE_TTL_MS,
11
- NEWS_CACHE_TTL_MS,
12
10
  OPTIONS_CACHE_TTL_MS,
13
11
  SEC_CONTENT_CACHE_TTL_MS,
14
12
  SEC_FILINGS_CACHE_TTL_MS,
@@ -23,7 +21,6 @@ import {
23
21
  import {
24
22
  buildArticleSummaryKey,
25
23
  buildFxKey,
26
- buildNewsKey,
27
24
  buildOptionsKey,
28
25
  buildSecContentKey,
29
26
  buildSecDocumentsKey,
@@ -42,46 +39,6 @@ interface AuxiliaryLoaderOptions<T> {
42
39
  load: (startedAt: number) => Promise<QueryEntry<T>>;
43
40
  }
44
41
 
45
- interface NewsDataProvider extends DataProvider {
46
- getNews?: (query: {
47
- feed: "ticker";
48
- ticker: string;
49
- exchange?: string;
50
- tickerTier: "primary";
51
- limit?: number;
52
- }) => Promise<NewsArticle[]>;
53
- }
54
-
55
- export function loadNewsEntry(options: {
56
- dataProvider: DataProvider;
57
- request: NewsRequest;
58
- store: QueryStore<NewsArticle[]>;
59
- runSingleFlight: RunSingleFlight;
60
- }): Promise<QueryEntry<NewsArticle[]>> {
61
- const { dataProvider, request, store, runSingleFlight } = options;
62
- const key = buildNewsKey(request);
63
- return loadAuxiliaryEntry({
64
- dataProvider,
65
- key,
66
- store,
67
- ttlMs: NEWS_CACHE_TTL_MS,
68
- runSingleFlight,
69
- load: async (startedAt) => {
70
- const newsProvider = dataProvider as NewsDataProvider;
71
- if (!newsProvider.getNews) throw new Error("No news provider available");
72
- const data = await newsProvider.getNews({
73
- feed: "ticker",
74
- ticker: request.instrument.symbol,
75
- exchange: request.instrument.exchange ?? "",
76
- tickerTier: "primary",
77
- limit: request.count ?? 50,
78
- });
79
- const attempts = [createAttempt(dataProvider.id, startedAt, data.length > 0 ? "success" : "empty", data.length === 0 ? "NO_DATA" : undefined)];
80
- return store.update(key, (current) => readyEntry(current, data.length > 0 ? data : null, dataProvider.id, attempts, { keepLastGoodOnEmpty: true }));
81
- },
82
- });
83
- }
84
-
85
42
  export function loadOptionsEntry(options: {
86
43
  dataProvider: DataProvider;
87
44
  request: OptionsRequest;
@@ -8,7 +8,6 @@ const EMPTY_MESSAGE = "No data available";
8
8
  export const EXPECTED_EMPTY = /no data|not found|delisted|unavailable|unsupported/i;
9
9
  export const SNAPSHOT_CACHE_TTL_MS = 5 * 60_000;
10
10
  export const CHART_CACHE_TTL_MS = 10 * 60_000;
11
- export const NEWS_CACHE_TTL_MS = 2 * 60_000;
12
11
  export const OPTIONS_CACHE_TTL_MS = 10 * 60_000;
13
12
  export const SEC_FILINGS_CACHE_TTL_MS = 10 * 60_000;
14
13
  export const SEC_CONTENT_CACHE_TTL_MS = 24 * 60 * 60_000;
@@ -155,13 +155,8 @@ describe("MarketDataCoordinator", () => {
155
155
  });
156
156
 
157
157
  it("reuses fresh empty tab query results instead of refetching on reopen", async () => {
158
- let newsCalls = 0;
159
158
  let optionsCalls = 0;
160
159
  const provider = createProvider({
161
- getNews: async () => {
162
- newsCalls += 1;
163
- return [];
164
- },
165
160
  getOptionsChain: async () => {
166
161
  optionsCalls += 1;
167
162
  return {
@@ -175,16 +170,11 @@ describe("MarketDataCoordinator", () => {
175
170
  const coordinator = new MarketDataCoordinator(provider);
176
171
  const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
177
172
 
178
- const firstNews = await coordinator.loadNews({ instrument, count: 20 });
179
- const secondNews = await coordinator.loadNews({ instrument, count: 20 });
180
173
  const firstOptions = await coordinator.loadOptions({ instrument });
181
174
  const secondOptions = await coordinator.loadOptions({ instrument });
182
175
 
183
- expect(firstNews.error?.reasonCode).toBe("NO_DATA");
184
- expect(secondNews.error?.reasonCode).toBe("NO_DATA");
185
176
  expect(firstOptions.error?.reasonCode).toBe("NO_DATA");
186
177
  expect(secondOptions.error?.reasonCode).toBe("NO_DATA");
187
- expect(newsCalls).toBe(1);
188
178
  expect(optionsCalls).toBe(1);
189
179
  });
190
180
 
@@ -1,7 +1,6 @@
1
1
  import type { DataProvider, SecFilingDocument, SecFilingItem } from "../../types/data-provider";
2
- import type { NewsArticle } from "../../news/types";
3
2
  import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../../types/financials";
4
- import type { ChartRequest, InstrumentRef, NewsRequest, OptionsRequest, SecFilingsRequest } from "../request-types";
3
+ import type { ChartRequest, InstrumentRef, OptionsRequest, SecFilingsRequest } from "../request-types";
5
4
  import { QueryStore } from "../query-store";
6
5
  import type { QueryEntry } from "../result-types";
7
6
  import {
@@ -9,7 +8,6 @@ import {
9
8
  buildChartKey,
10
9
  buildFxKey,
11
10
  buildTickerFinancialsSnapshot,
12
- buildNewsKey,
13
11
  buildOptionsKey,
14
12
  buildQuoteKey,
15
13
  buildSecContentKey,
@@ -41,7 +39,6 @@ import {
41
39
  import {
42
40
  loadArticleSummaryEntry,
43
41
  loadFxRateEntry,
44
- loadNewsEntry,
45
42
  loadOptionsEntry,
46
43
  loadSecFilingContentEntry,
47
44
  loadSecFilingDocumentsEntry,
@@ -73,7 +70,6 @@ export class MarketDataCoordinator {
73
70
  private readonly fundamentalsStore = new QueryStore<TickerFinancials["fundamentals"]>((key) => this.events.bump(key));
74
71
  private readonly statementsStore = new QueryStore<Pick<TickerFinancials, "annualStatements" | "quarterlyStatements">>((key) => this.events.bump(key));
75
72
  private readonly chartStore = new QueryStore<PricePoint[]>((key) => this.events.bump(key));
76
- private readonly newsStore = new QueryStore<NewsArticle[]>((key) => this.events.bump(key));
77
73
  private readonly optionsStore = new QueryStore<OptionsChain>((key) => this.events.bump(key));
78
74
  private readonly secFilingsStore = new QueryStore<SecFilingItem[]>((key) => this.events.bump(key));
79
75
  private readonly secDocumentsStore = new QueryStore<SecFilingDocument[]>((key) => this.events.bump(key));
@@ -124,10 +120,6 @@ export class MarketDataCoordinator {
124
120
  return this.chartStore.get(buildChartKey(request));
125
121
  }
126
122
 
127
- getNewsEntry(request: NewsRequest): QueryEntry<NewsArticle[]> {
128
- return this.newsStore.get(buildNewsKey(request));
129
- }
130
-
131
123
  getOptionsEntry(request: OptionsRequest): QueryEntry<OptionsChain> {
132
124
  return this.optionsStore.get(buildOptionsKey(request));
133
125
  }
@@ -311,15 +303,6 @@ export class MarketDataCoordinator {
311
303
  });
312
304
  }
313
305
 
314
- async loadNews(request: NewsRequest): Promise<QueryEntry<NewsArticle[]>> {
315
- return loadNewsEntry({
316
- dataProvider: this.dataProvider,
317
- request,
318
- store: this.newsStore,
319
- runSingleFlight: (key, task) => this.runSingleFlight(key, task),
320
- });
321
- }
322
-
323
306
  async loadOptions(request: OptionsRequest): Promise<QueryEntry<OptionsChain>> {
324
307
  return loadOptionsEntry({
325
308
  dataProvider: this.dataProvider,
@@ -228,7 +228,6 @@ export class QuoteSubscriptionManager {
228
228
  private nextQuoteSubscriptionId = 1;
229
229
  private quoteSubscriptionDispose: (() => void) | null = null;
230
230
  private quoteSubscriptionSignature = "";
231
- private pendingQuoteSubscriptionKeys = new Set<string>();
232
231
 
233
232
  constructor(
234
233
  private readonly dataProvider: DataProvider,
@@ -250,7 +249,6 @@ export class QuoteSubscriptionManager {
250
249
  if (existing) {
251
250
  existing.targets.set(subscriptionId, target);
252
251
  existing.target = mergeQuoteSubscriptionTargets(existing.targets.values()) ?? target;
253
- this.pendingQuoteSubscriptionKeys.add(key);
254
252
  if (existing.removeTimer) {
255
253
  clearTimeout(existing.removeTimer);
256
254
  existing.removeTimer = null;
@@ -262,7 +260,6 @@ export class QuoteSubscriptionManager {
262
260
  targets: new Map([[subscriptionId, target]]),
263
261
  removeTimer: null,
264
262
  });
265
- this.pendingQuoteSubscriptionKeys.add(key);
266
263
  }
267
264
  this.flush();
268
265
 
@@ -274,7 +271,6 @@ export class QuoteSubscriptionManager {
274
271
  const mergedTarget = mergeQuoteSubscriptionTargets(existing.targets.values());
275
272
  if (mergedTarget) {
276
273
  existing.target = mergedTarget;
277
- this.pendingQuoteSubscriptionKeys.add(key);
278
274
  this.flush();
279
275
  continue;
280
276
  }
@@ -283,7 +279,6 @@ export class QuoteSubscriptionManager {
283
279
  const current = this.quoteSubscriptions.get(key);
284
280
  if (!current || current.targets.size > 0) return;
285
281
  this.quoteSubscriptions.delete(key);
286
- this.pendingQuoteSubscriptionKeys.delete(key);
287
282
  this.flush();
288
283
  }, QUOTE_SUBSCRIPTION_REMOVE_GRACE_MS);
289
284
  }
@@ -292,7 +287,6 @@ export class QuoteSubscriptionManager {
292
287
 
293
288
  private flush(): void {
294
289
  if (!this.dataProvider.subscribeQuotes) return;
295
- this.pendingQuoteSubscriptionKeys = new Set();
296
290
 
297
291
  const activeEntries = [...this.quoteSubscriptions.entries()]
298
292
  .filter(([, entry]) => entry.targets.size > 0 || entry.removeTimer)
@@ -40,6 +40,7 @@ function quote(symbol: string, price: number, overrides: Partial<Quote> = {}): Q
40
40
 
41
41
  async function flushCoordinator(): Promise<void> {
42
42
  await Promise.resolve();
43
+ await new Promise((resolve) => setTimeout(resolve, 0));
43
44
  }
44
45
 
45
46
  describe("MarketDataCoordinator key subscriptions", () => {
@@ -1,5 +1,7 @@
1
1
  import type { MarketState, Quote, QuoteFieldProvenance } from "../../types/financials";
2
- import { colors } from "../../theme/colors";
2
+ import { blendHex, colors, priceColor } from "../../theme/colors";
3
+
4
+ const CLOSED_CHANGE_MUTING_RATIO = 0.55;
3
5
 
4
6
  export interface ActiveQuoteDisplay {
5
7
  price: number;
@@ -45,6 +47,22 @@ export function marketStateDot(state?: MarketState): string {
45
47
  }
46
48
  }
47
49
 
50
+ export function isClosedMarketState(state?: MarketState): boolean {
51
+ return state === "CLOSED" || state === "PREPRE" || state === "POSTPOST";
52
+ }
53
+
54
+ /** Closed prices are final snapshots, so they should not look live or directional. */
55
+ export function marketPriceColor(change: number, state?: MarketState): string {
56
+ return isClosedMarketState(state) ? colors.textDim : priceColor(change);
57
+ }
58
+
59
+ /** Preserve a closed session's direction while visually distinguishing it from a live move. */
60
+ export function marketChangeColor(change: number, state?: MarketState): string {
61
+ const directionalColor = priceColor(change);
62
+ if (!isClosedMarketState(state) || change === 0) return directionalColor;
63
+ return blendHex(directionalColor, colors.textDim, CLOSED_CHANGE_MUTING_RATIO);
64
+ }
65
+
48
66
  /** Short exchange display name */
49
67
  export function exchangeShortName(exchangeName?: string, fullExchangeName?: string): string {
50
68
  if (!exchangeName && !fullExchangeName) return "";
@@ -21,8 +21,4 @@ export class QueryStore<T> {
21
21
  this.onChange(key);
22
22
  return next;
23
23
  }
24
-
25
- values(): IterableIterator<QueryEntry<T>> {
26
- return this.entries.values();
27
- }
28
24
  }
@@ -147,7 +147,7 @@ function assignField(
147
147
  quote: QuoteContribution | undefined,
148
148
  ): void {
149
149
  if (!quote) return;
150
- const value = (quote as Record<string, unknown>)[field];
150
+ const value = (quote as unknown as Record<string, unknown>)[field];
151
151
  if (value === undefined) return;
152
152
  (target as Record<string, unknown>)[field] = value;
153
153
  provenance.fields ??= {};
@@ -161,7 +161,7 @@ function pickField(
161
161
  candidates: QuoteContribution[],
162
162
  ): QuoteContribution | undefined {
163
163
  for (const candidate of candidates) {
164
- if ((candidate as Record<string, unknown>)[field] === undefined) continue;
164
+ if ((candidate as unknown as Record<string, unknown>)[field] === undefined) continue;
165
165
  assignField(target, provenance, field, candidate);
166
166
  return candidate;
167
167
  }
@@ -28,11 +28,6 @@ export interface ChartRequest {
28
28
  barSize?: string;
29
29
  }
30
30
 
31
- export interface NewsRequest {
32
- instrument: InstrumentRef;
33
- count?: number;
34
- }
35
-
36
31
  export interface OptionsRequest {
37
32
  instrument: InstrumentRef;
38
33
  expirationDate?: number;
@@ -19,17 +19,6 @@ export interface ProviderAttempt {
19
19
  message?: string;
20
20
  }
21
21
 
22
- interface ProviderResult<T> {
23
- status: ProviderStatus;
24
- data: T | null;
25
- providerId: string;
26
- latencyMs: number;
27
- completeness: "full" | "partial";
28
- reasonCode?: ProviderReasonCode;
29
- asOf?: number;
30
- staleAt?: number;
31
- }
32
-
33
22
  type QueryPhase = "idle" | "loading" | "ready" | "refreshing" | "error";
34
23
 
35
24
  export interface QueryEntry<T> {
@@ -1,6 +1,6 @@
1
1
  import type { MarketDataRequestContext } from "../types/data-provider";
2
2
  import type { Quote, TickerFinancials } from "../types/financials";
3
- import type { InstrumentRef, NewsRequest, OptionsRequest, SecFilingsRequest, ChartRequest } from "./request-types";
3
+ import type { InstrumentRef, OptionsRequest, SecFilingsRequest, ChartRequest } from "./request-types";
4
4
  import type { QueryEntry } from "./result-types";
5
5
  import { hasLikelyQuoteUnitMismatch } from "../utils/currency-units";
6
6
  import { normalizePriceHistory } from "../utils/price-history";
@@ -61,10 +61,6 @@ export function buildChartKey(request: ChartRequest): string {
61
61
  ].join(":");
62
62
  }
63
63
 
64
- export function buildNewsKey(request: NewsRequest): string {
65
- return `news:${buildInstrumentKey(request.instrument)}:${request.count ?? 50}`;
66
- }
67
-
68
64
  export function buildOptionsKey(request: OptionsRequest): string {
69
65
  return `options:${buildInstrumentKey(request.instrument)}:${request.expirationDate ?? "default"}`;
70
66
  }
@@ -58,7 +58,11 @@ export class NewsService {
58
58
  if (this.pollTimer !== null) {
59
59
  void this.pollActiveQueries();
60
60
  }
61
- return () => this.unregister(source.id);
61
+ return () => {
62
+ if (this.sources.get(source.id) === source) {
63
+ this.unregister(source.id);
64
+ }
65
+ };
62
66
  }
63
67
 
64
68
  unregister(sourceId: string): void {
@@ -3,24 +3,6 @@ import { cloneLayout, createDefaultConfig, createPaneInstance, DEFAULT_PORTFOLIO
3
3
  import { deletePaneSetting, getPaneSettingValue, setPaneSetting } from "./pane-settings";
4
4
 
5
5
  describe("pane settings helpers", () => {
6
- test("seeds built-in default panes with persisted settings", () => {
7
- const config = createDefaultConfig("/tmp/gloomberb-pane-settings");
8
- const portfolioPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
9
- const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
10
-
11
- expect(portfolioPane?.settings).toMatchObject({
12
- collectionScope: "all",
13
- viewMode: "table",
14
- });
15
- expect(portfolioPane?.settings?.columnIds).toEqual(DEFAULT_PORTFOLIO_COLUMN_IDS);
16
- expect(detailPane?.settings).toEqual({
17
- chartRangePreset: "5Y",
18
- chartResolution: "auto",
19
- hideTabs: false,
20
- lockedTabId: "overview",
21
- });
22
- });
23
-
24
6
  test("cloneLayout deep-clones pane settings", () => {
25
7
  const layout = cloneLayout(createDefaultConfig("/tmp/gloomberb-pane-settings").layout);
26
8
  const cloned = cloneLayout(layout);