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
@@ -22,6 +22,7 @@ export function toKeyEventLike(event: {
22
22
  shift?: boolean;
23
23
  alt?: boolean;
24
24
  meta?: boolean;
25
+ option?: boolean;
25
26
  preventDefault?: () => void;
26
27
  stopPropagation?: () => void;
27
28
  defaultPrevented?: boolean;
@@ -37,9 +38,12 @@ export function toKeyEventLike(event: {
37
38
  sequence: event.sequence,
38
39
  ctrl: event.ctrl ?? false,
39
40
  shift: event.shift ?? false,
40
- alt: event.alt ?? false,
41
- meta: event.meta ?? event.super ?? false,
42
- super: event.super ?? event.meta ?? false,
41
+ // OpenTUI follows terminal naming: `meta` is Alt/Option, while the
42
+ // kitty keyboard protocol exposes the Command/Windows modifier as
43
+ // `super`. Keep the shared input model aligned with browser semantics.
44
+ alt: event.alt === true || event.meta === true || event.option === true,
45
+ meta: event.super === true,
46
+ super: event.super === true,
43
47
  get defaultPrevented() {
44
48
  return event.defaultPrevented ?? false;
45
49
  },
@@ -101,6 +105,9 @@ export async function createOpenTuiHost(): Promise<OpenTuiHost> {
101
105
  await proc.exited;
102
106
  },
103
107
  async copyText(text) {
108
+ if (!text) return;
109
+ if (renderer.copyToClipboardOSC52(text)) return;
110
+ if (process.platform !== "darwin") return;
104
111
  const proc = Bun.spawn(["pbcopy"], {
105
112
  stdin: "pipe",
106
113
  stdout: "ignore",
@@ -111,6 +118,7 @@ export async function createOpenTuiHost(): Promise<OpenTuiHost> {
111
118
  await proc.exited;
112
119
  },
113
120
  async readText() {
121
+ if (process.platform !== "darwin") return "";
114
122
  const proc = Bun.spawn(["pbpaste"], {
115
123
  stdout: "pipe",
116
124
  stderr: "ignore",
@@ -1,4 +1,5 @@
1
1
  import { useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
2
+ import { useNativeRenderer } from "../../ui";
2
3
  import {
3
4
  InputHostProvider,
4
5
  shouldDeliverShortcut,
@@ -19,6 +20,7 @@ interface ShortcutEntry {
19
20
  let nextShortcutOrder = 1;
20
21
 
21
22
  export function OpenTuiInputHostProvider({ children }: { children: ReactNode }) {
23
+ const renderer = useNativeRenderer();
22
24
  const shortcutsRef = useRef<ShortcutEntry[]>([]);
23
25
  const dispatchShortcut = (event: KeyEventLike) => {
24
26
  const shortcuts = shortcutsRef.current;
@@ -34,7 +36,9 @@ export function OpenTuiInputHostProvider({ children }: { children: ReactNode })
34
36
  };
35
37
 
36
38
  useKeyboard((event) => {
37
- dispatchShortcut(toKeyEventLike(event));
39
+ const shortcutEvent = toKeyEventLike(event);
40
+ shortcutEvent.targetEditable = renderer.currentFocusedEditor != null;
41
+ dispatchShortcut(shortcutEvent);
38
42
  });
39
43
 
40
44
  const host = useMemo<InputHost>(() => ({
@@ -450,78 +450,6 @@ describe("GloomberbCloudProvider", () => {
450
450
  expect(unsubscribeCalled).toBe(true);
451
451
  });
452
452
 
453
- test("maps backend news stories without requiring verified market-data access", async () => {
454
- type CloudNewsRequest = NonNullable<Parameters<typeof apiClient.getCloudNews>[0]>;
455
- let verifyCalled = false;
456
- const requestArgs: { current: CloudNewsRequest | null } = { current: null };
457
- apiClient.ensureVerifiedSession = async () => {
458
- verifyCalled = true;
459
- return verifiedUser;
460
- };
461
- apiClient.getCloudNews = async (params = {}) => {
462
- requestArgs.current = params;
463
- return {
464
- items: [{
465
- id: "story-1",
466
- headline: "Apple raises guidance",
467
- summary: "Apple lifted its outlook after stronger iPhone demand.",
468
- topic: "guidance",
469
- topics: ["guidance"],
470
- category: "guidance",
471
- sentiment: "positive",
472
- sectors: ["information_technology"],
473
- firstPublishedAt: "2026-04-01T10:00:00.000Z",
474
- lastPublishedAt: "2026-04-01T10:05:00.000Z",
475
- firstSeenAt: "2026-04-01T10:00:10.000Z",
476
- lastSeenAt: "2026-04-01T10:05:10.000Z",
477
- primaryUrl: "https://example.com/aapl-guidance",
478
- primarySource: "example-wire",
479
- scores: {
480
- importance: 91,
481
- urgency: 74,
482
- marketImpact: 88,
483
- novelty: 86,
484
- confidence: 95,
485
- },
486
- flags: {
487
- breaking: true,
488
- developing: false,
489
- stale: false,
490
- },
491
- variantCount: 1,
492
- sourceCount: 1,
493
- sources: ["example-wire"],
494
- entities: [],
495
- tickerLinks: [{
496
- symbol: "AAPL",
497
- exchange: "XNAS",
498
- canonicalTicker: "AAPL:XNAS",
499
- relationType: "direct",
500
- displayTier: "primary",
501
- confidence: 0.98,
502
- relevanceScore: 95,
503
- impactScore: 93,
504
- sentiment: "positive",
505
- }],
506
- }],
507
- nextCursor: null,
508
- };
509
- };
510
-
511
- const provider = new GloomberbCloudProvider();
512
- const news = await provider.getNews("AAPL", 10, "NASDAQ");
513
-
514
- expect(verifyCalled).toBe(false);
515
- expect(requestArgs.current).toEqual({ feed: "ticker", ticker: "AAPL", exchange: "XNAS", tickerTier: "primary", limit: 10 });
516
- expect(news).toEqual([{
517
- title: "Apple raises guidance",
518
- url: "https://example.com/aapl-guidance",
519
- source: "example-wire",
520
- publishedAt: new Date("2026-04-01T10:05:00.000Z"),
521
- summary: "Apple lifted its outlook after stronger iPhone demand.",
522
- }]);
523
- });
524
-
525
453
  test("maps news ticker labels from validated story links only", async () => {
526
454
  apiClient.getCloudNews = async () => ({
527
455
  items: [{
@@ -9,11 +9,9 @@ import type {
9
9
  AssetDataProvider,
10
10
  CachedFinancialsTarget,
11
11
  MarketDataRequestContext,
12
- NewsItem,
13
12
  QuoteBatchResult,
14
13
  QuoteSubscriptionTarget,
15
14
  SearchRequestContext,
16
- SecFilingItem,
17
15
  TickerFinancialsBatchResult,
18
16
  } from "../../types/data-provider";
19
17
  import type { AnalystResearchData, CorporateActionsData, HolderData, OptionsChain, PricePoint, Quote, TickerFinancials } from "../../types/financials";
@@ -27,12 +25,11 @@ import {
27
25
  } from "../../api-client";
28
26
  import type { NewsArticle, NewsQuery } from "../../types/news-source";
29
27
  import { resolvePriceHistoryCurrencyUnit } from "../../utils/currency-units";
30
- import { canonicalTickerKey, publicExchange } from "../../utils/exchanges";
28
+ import { canonicalTickerKey } from "../../utils/exchanges";
31
29
  import { createProviderMiss } from "../provider-errors";
32
30
  import {
33
31
  cloudNewsParams,
34
32
  mapCloudNewsArticle,
35
- mapCloudNewsItem,
36
33
  } from "./news";
37
34
  import {
38
35
  GLOOMBERB_CLOUD_PROVIDER_ID,
@@ -237,20 +234,6 @@ export class GloomberbCloudProvider implements AssetDataProvider {
237
234
  );
238
235
  }
239
236
 
240
- async getNews(ticker: string, count?: number, exchange?: string, _context?: MarketDataRequestContext): Promise<NewsItem[]> {
241
- const response = await withCloudFallback(
242
- () => apiClient.getCloudNews({
243
- feed: "ticker",
244
- ticker,
245
- exchange: exchange ? publicExchange(exchange) : undefined,
246
- tickerTier: "primary",
247
- limit: count,
248
- }),
249
- `Cloud news is unavailable for ${ticker}`,
250
- );
251
- return response.items.map(mapCloudNewsItem);
252
- }
253
-
254
237
  async getHolders(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise<HolderData> {
255
238
  await requireVerifiedSession();
256
239
  return withCloudFallback(async () => {
@@ -275,18 +258,6 @@ export class GloomberbCloudProvider implements AssetDataProvider {
275
258
  }, `Cloud corporate actions are unavailable for ${ticker}`);
276
259
  }
277
260
 
278
- async getSecFilings(_ticker: string, _count?: number, _exchange?: string, _context?: MarketDataRequestContext): Promise<SecFilingItem[]> {
279
- throw createProviderMiss("Cloud SEC filings are not available");
280
- }
281
-
282
- async getSecFilingDocuments(_filing: SecFilingItem) {
283
- throw createProviderMiss("Cloud filing documents are not available");
284
- }
285
-
286
- async getSecFilingContent(_filing: SecFilingItem): Promise<string | null> {
287
- throw createProviderMiss("Cloud filing content is not available");
288
- }
289
-
290
261
  async getArticleSummary(_url: string): Promise<string | null> {
291
262
  throw createProviderMiss("Cloud article summaries are not available");
292
263
  }
@@ -1,5 +1,4 @@
1
1
  import { collectNewsDisplayTickers } from "../../news/ticker-symbols";
2
- import type { NewsItem } from "../../types/data-provider";
3
2
  import type { NewsArticle, NewsQuery, NewsStoryItem } from "../../types/news-source";
4
3
  import type {
5
4
  CloudNewsPayload,
@@ -90,17 +89,6 @@ export function mapCloudNewsArticle(
90
89
  };
91
90
  }
92
91
 
93
- export function mapCloudNewsItem(item: CloudNewsPayload): NewsItem {
94
- const article = mapCloudNewsArticle(item);
95
- return {
96
- title: article.title,
97
- url: article.url,
98
- source: article.source,
99
- publishedAt: article.publishedAt,
100
- summary: article.summary,
101
- };
102
- }
103
-
104
92
  export function cloudNewsParams(query: NewsQuery): CloudNewsParams {
105
93
  const feed = query.feed ?? (query.scope === "ticker" ? "ticker" : "latest");
106
94
  return {
@@ -43,6 +43,19 @@ export interface ProviderRouterFinancialRouteDeps extends Pick<
43
43
  primaryRoutes: ProviderRouterPrimaryRoutes;
44
44
  }
45
45
 
46
+ function withoutSessionState(quote: Quote): Quote {
47
+ const fallback = { ...quote };
48
+ delete fallback.marketState;
49
+ delete fallback.sessionConfidence;
50
+ delete fallback.preMarketPrice;
51
+ delete fallback.preMarketChange;
52
+ delete fallback.preMarketChangePercent;
53
+ delete fallback.postMarketPrice;
54
+ delete fallback.postMarketChange;
55
+ delete fallback.postMarketChangePercent;
56
+ return fallback;
57
+ }
58
+
46
59
  export class ProviderRouterFinancialRoutes {
47
60
  constructor(private readonly deps: ProviderRouterFinancialRouteDeps) {}
48
61
 
@@ -169,16 +182,21 @@ export class ProviderRouterFinancialRoutes {
169
182
  exchange?: string,
170
183
  context?: MarketDataRequestContext,
171
184
  ): Promise<Quote | null> {
172
- const cached = this.readCachedProviderQuote(ticker, exchange, context);
185
+ const cached = context?.cacheMode === "refresh"
186
+ ? null
187
+ : this.readCachedProviderQuote(ticker, exchange, context);
173
188
  if (cached) return cached;
189
+ const staleFallback = this.readCachedProviderQuote(ticker, exchange, context, true);
174
190
  const providerQuote = await this.deps.primaryRoutes.fetchProviderQuote(ticker, exchange, context);
175
- return providerQuote?.value ?? null;
191
+ if (providerQuote) return providerQuote.value;
192
+ return staleFallback ? withoutSessionState(staleFallback) : null;
176
193
  }
177
194
 
178
195
  private readCachedProviderQuote(
179
196
  ticker: string,
180
197
  exchange?: string,
181
198
  context?: MarketDataRequestContext,
199
+ includeStale = false,
182
200
  ): Quote | null {
183
201
  const entityKey = this.deps.getEntityKey(ticker, context?.instrument);
184
202
  const entityKeys = [...new Set([entityKey, normalizeTicker(ticker)])];
@@ -186,7 +204,11 @@ export class ProviderRouterFinancialRoutes {
186
204
  const sourceKeys = this.deps.getProviderSourceKeys();
187
205
  for (const candidateEntityKey of entityKeys) {
188
206
  const record = selectCachedResource<Quote>(this.deps.resources, "quote", candidateEntityKey, variantKeys, sourceKeys, false);
189
- if (record && !isQuoteStaleForCurrentSession(quoteWithFreshnessExchange(record.value, exchange))) {
207
+ if (
208
+ record
209
+ && (includeStale || !record.stale)
210
+ && !isQuoteStaleForCurrentSession(quoteWithFreshnessExchange(record.value, exchange))
211
+ ) {
190
212
  return record.value;
191
213
  }
192
214
  }
@@ -1,4 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
+ import { CloudApiRequestTransport } from "../../api-client/request";
2
3
  import { AppPersistence } from "../../data/app-persistence";
3
4
  import { AssetDataRouter } from "./index";
4
5
  import { assetDataProvider } from "../../capabilities";
@@ -26,6 +27,59 @@ afterEach(() => {
26
27
  });
27
28
 
28
29
  describe("AssetDataRouter", () => {
30
+ test("falls through to Yahoo quotes and history when Cloud market requests never settle", async () => {
31
+ const cloudCalls = { quote: 0, history: 0 };
32
+ const yahooCalls = { quote: 0, history: 0 };
33
+ const cloudTransport = new CloudApiRequestTransport({
34
+ marketRequestTimeoutMs: 10,
35
+ fetchTransport: async () => new Promise<Response>(() => {}),
36
+ });
37
+ const cloudProvider: DataProvider = {
38
+ ...fallbackProvider,
39
+ id: "gloomberb-cloud",
40
+ name: "Cloud",
41
+ priority: 100,
42
+ async getQuote() {
43
+ cloudCalls.quote += 1;
44
+ await cloudTransport.request("/market/quote?symbol=AAPL");
45
+ throw new Error("unreachable");
46
+ },
47
+ async getPriceHistory() {
48
+ cloudCalls.history += 1;
49
+ await cloudTransport.request("/market/history?symbol=AAPL&range=1Y");
50
+ throw new Error("unreachable");
51
+ },
52
+ };
53
+ const yahooProvider: DataProvider = {
54
+ ...fallbackProvider,
55
+ id: "yahoo",
56
+ name: "Yahoo",
57
+ priority: 1000,
58
+ async getQuote() {
59
+ yahooCalls.quote += 1;
60
+ return makeQuote({ providerId: "yahoo", price: 212.5 });
61
+ },
62
+ async getPriceHistory() {
63
+ yahooCalls.history += 1;
64
+ return [
65
+ { date: new Date("2026-07-10T00:00:00Z"), close: 210 },
66
+ { date: new Date("2026-07-11T00:00:00Z"), close: 212.5 },
67
+ ];
68
+ },
69
+ };
70
+ const router = new AssetDataRouter(yahooProvider, [cloudProvider]);
71
+ console.error = () => {};
72
+
73
+ const quote = await router.getQuote("AAPL", "NASDAQ");
74
+ const history = await router.getPriceHistory("AAPL", "NASDAQ", "1Y");
75
+
76
+ expect(quote.providerId).toBe("yahoo");
77
+ expect(quote.price).toBe(212.5);
78
+ expect(history.map((point) => point.close)).toEqual([210, 212.5]);
79
+ expect(cloudCalls).toEqual({ quote: 1, history: 1 });
80
+ expect(yahooCalls).toEqual({ quote: 1, history: 1 });
81
+ });
82
+
29
83
  test("serves USD exchange rate locally without provider revalidation", async () => {
30
84
  let providerCalls = 0;
31
85
  const router = new AssetDataRouter({
@@ -367,6 +421,119 @@ describe("AssetDataRouter", () => {
367
421
  expect(quote.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
368
422
  });
369
423
 
424
+ test("refreshes broker option session references across the market close", async () => {
425
+ const dbPath = createTempDbPath("option-session-transition");
426
+ const persistence = new AppPersistence(dbPath);
427
+ const optionSymbol = "IBIT 281215C00030000";
428
+ const entityKey = `contract:${optionSymbol}`;
429
+ const providerSourceKey = "provider:yahoo";
430
+ const cacheKey = {
431
+ namespace: "market",
432
+ kind: "quote",
433
+ entityKey,
434
+ variantKey: "",
435
+ sourceKey: providerSourceKey,
436
+ };
437
+ const regularReference = makeQuote({
438
+ symbol: optionSymbol,
439
+ providerId: "yahoo",
440
+ price: 15.775,
441
+ change: 0.29,
442
+ changePercent: 1.87,
443
+ previousClose: 15.485,
444
+ listingExchangeName: "OPTIONS",
445
+ marketState: "REGULAR",
446
+ sessionConfidence: "derived",
447
+ });
448
+ let providerCalls = 0;
449
+ let providerAvailable = true;
450
+ const provider: DataProvider = {
451
+ ...fallbackProvider,
452
+ id: "yahoo",
453
+ name: "Yahoo",
454
+ async getQuote() {
455
+ providerCalls += 1;
456
+ if (!providerAvailable) throw new Error("temporary provider outage");
457
+ return {
458
+ ...regularReference,
459
+ marketState: "CLOSED",
460
+ lastUpdated: Date.now(),
461
+ };
462
+ },
463
+ };
464
+ const broker: BrokerAdapter = {
465
+ id: "ibkr",
466
+ name: "IBKR",
467
+ configSchema: [],
468
+ async validate() {
469
+ return true;
470
+ },
471
+ async importPositions() {
472
+ return [];
473
+ },
474
+ async getQuote() {
475
+ return {
476
+ ...regularReference,
477
+ providerId: "ibkr",
478
+ previousClose: 99,
479
+ marketState: undefined,
480
+ sessionConfidence: "unknown",
481
+ dataSource: "live",
482
+ lastUpdated: Date.now(),
483
+ };
484
+ },
485
+ };
486
+
487
+ try {
488
+ persistence.resources.set(cacheKey, regularReference, {
489
+ cachePolicy: { staleMs: 60_000, expireMs: 24 * 60 * 60_000 },
490
+ fetchedAt: Date.now() - 2 * 60_000,
491
+ });
492
+ const router = new AssetDataRouter(provider, [], persistence.resources);
493
+ attachTestRegistry(router, { brokers: [["ibkr", broker]] });
494
+ setBrokerInstances(router, [brokerInstance()]);
495
+ const context = {
496
+ brokerId: "ibkr",
497
+ brokerInstanceId: "ibkr-work",
498
+ instrument: {
499
+ brokerId: "ibkr",
500
+ brokerInstanceId: "ibkr-work",
501
+ symbol: "IBIT",
502
+ localSymbol: optionSymbol,
503
+ secType: "OPT",
504
+ },
505
+ } as const;
506
+
507
+ const staleTransition = await router.getQuote(optionSymbol, "", context);
508
+ expect(staleTransition.marketState).toBe("CLOSED");
509
+ expect(providerCalls).toBe(1);
510
+
511
+ persistence.resources.set(cacheKey, regularReference, {
512
+ cachePolicy: { staleMs: 60_000, expireMs: 24 * 60 * 60_000 },
513
+ fetchedAt: Date.now(),
514
+ });
515
+ const forcedTransition = await router.getQuote(optionSymbol, "", {
516
+ ...context,
517
+ cacheMode: "refresh",
518
+ });
519
+ expect(forcedTransition.marketState).toBe("CLOSED");
520
+ expect(providerCalls).toBe(2);
521
+
522
+ providerAvailable = false;
523
+ persistence.resources.set(cacheKey, regularReference, {
524
+ cachePolicy: { staleMs: 60_000, expireMs: 24 * 60 * 60_000 },
525
+ fetchedAt: Date.now() - 2 * 60_000,
526
+ });
527
+ const outageFallback = await router.getQuote(optionSymbol, "", context);
528
+ expect(outageFallback.previousClose).toBe(15.485);
529
+ expect(outageFallback.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
530
+ expect(outageFallback.marketState).toBeUndefined();
531
+ expect(providerCalls).toBe(3);
532
+ } finally {
533
+ persistence.close();
534
+ }
535
+ });
536
+
370
537
  test("prefers broker options chains for broker-context targets", async () => {
371
538
  const chain = {
372
539
  underlyingSymbol: "AAPL",
@@ -4,6 +4,7 @@ import type { BrokerCandidate } from "./brokers";
4
4
  import { shouldLogProviderError } from "../provider-errors";
5
5
 
6
6
  const SEARCH_CACHE_TTL_MS = 30_000;
7
+ const SEARCH_CACHE_MAX_ENTRIES = 100;
7
8
 
8
9
  const SEARCH_PROVIDER_TIMEOUT_MS = 5_000;
9
10
 
@@ -88,6 +89,7 @@ export class ProviderRouterSearchRoutes {
88
89
  const cacheKey = buildSearchCacheKey(query, context);
89
90
  const cached = this.searchCache.get(cacheKey);
90
91
  if (cached && cached.expiresAt > Date.now()) return cached.results;
92
+ if (cached) this.searchCache.delete(cacheKey);
91
93
  const inFlight = this.searchInFlight.get(cacheKey);
92
94
  if (inFlight) return inFlight;
93
95
 
@@ -111,6 +113,9 @@ export class ProviderRouterSearchRoutes {
111
113
  expiresAt: Date.now() + ttl,
112
114
  results,
113
115
  });
116
+ if (this.searchCache.size > SEARCH_CACHE_MAX_ENTRIES) {
117
+ this.searchCache.delete(this.searchCache.keys().next().value!);
118
+ }
114
119
  };
115
120
  const push = (items: InstrumentSearchResult[]) => {
116
121
  mergeSearchResults(results, resultIndexByKey, items, context);
@@ -57,7 +57,7 @@ export class ProviderRouterSupplementalRoutes {
57
57
 
58
58
  const result = await firstProviderResult(this.deps, async (provider) => {
59
59
  const rate = await provider.getExchangeRate(normalizedCurrency);
60
- return { provider, rate };
60
+ return Number.isFinite(rate) && rate > 0 ? { provider, rate } : null;
61
61
  });
62
62
  if (!result) {
63
63
  throw new Error(`No exchange rate provider available for ${normalizedCurrency}`);
@@ -164,7 +164,8 @@ export class ProviderRouterSupplementalRoutes {
164
164
  if (normalizedCurrency === "USD") return 1;
165
165
  const entityKey = `${normalizedCurrency}/USD`;
166
166
  const cached = selectCachedResource<{ rate: number }>(this.deps.resources, "exchange-rate", entityKey, [""], this.deps.getProviderSourceKeys(), allowExpired);
167
- return cached?.value.rate ?? null;
167
+ const rate = cached?.value.rate;
168
+ return typeof rate === "number" && Number.isFinite(rate) && rate > 0 ? rate : null;
168
169
  }
169
170
 
170
171
  private cacheExchangeRate(currency: string, rate: number, provider: DataProvider): void {
@@ -341,7 +342,7 @@ export class ProviderRouterSupplementalRoutes {
341
342
 
342
343
  const result = await firstProviderResult(this.deps, async (provider) => {
343
344
  const rate = await provider.getExchangeRate(normalizedCurrency);
344
- return { provider, rate };
345
+ return Number.isFinite(rate) && rate > 0 ? { provider, rate } : null;
345
346
  });
346
347
  if (!result) return;
347
348
  this.cacheExchangeRate(normalizedCurrency, result.value.rate, result.value.provider);
@@ -33,7 +33,11 @@ type YahooChartFetcher = (
33
33
  symbol: string,
34
34
  range: string,
35
35
  interval: ManualChartResolution,
36
- ) => Promise<ChartResult>;
36
+ ) => Promise<{
37
+ meta: NonNullable<ChartResult["meta"]>;
38
+ history: PricePoint[];
39
+ events?: ChartResult["events"];
40
+ }>;
37
41
 
38
42
  export function getYahooChartResolutionSupport(): ChartResolutionSupport[] {
39
43
  return YAHOO_RESOLUTION_SUPPORT;