gloomberb 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +2 -2
  3. package/package.json +2 -1
  4. package/src/api-client/auth.ts +8 -0
  5. package/src/api-client/index.ts +70 -0
  6. package/src/api-client/types.ts +49 -0
  7. package/src/app/runtime/desktop-deeplink.test.ts +8 -0
  8. package/src/app/runtime/desktop-deeplink.ts +14 -1
  9. package/src/components/chart/composite/composite-chart.test.tsx +340 -17
  10. package/src/components/chart/composite/composite-chart.tsx +859 -68
  11. package/src/components/chart/composite/interactions.test.ts +78 -22
  12. package/src/components/chart/composite/interactions.ts +45 -17
  13. package/src/components/chart/composite/rasterizer.ts +8 -2
  14. package/src/components/chart/composite/renderers.test.ts +61 -1
  15. package/src/components/chart/composite/scene.ts +14 -4
  16. package/src/components/chart/composite/tools.test.ts +371 -0
  17. package/src/components/chart/composite/tools.ts +508 -0
  18. package/src/components/chart/composite/types.ts +4 -0
  19. package/src/components/chart/core/pointer.ts +2 -0
  20. package/src/components/chart/native/raster/primitives.ts +18 -0
  21. package/src/components/chart/price-axis-labels.tsx +42 -2
  22. package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
  23. package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
  24. package/src/components/command-bar/action-runtime.ts +3 -0
  25. package/src/components/command-bar/assist/inventory.test.ts +93 -0
  26. package/src/components/command-bar/assist/inventory.ts +92 -0
  27. package/src/components/command-bar/assist/model.test.ts +104 -0
  28. package/src/components/command-bar/assist/model.ts +169 -0
  29. package/src/components/command-bar/assist/runtime.ts +185 -0
  30. package/src/components/command-bar/commands/direct/index.ts +1 -1
  31. package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
  32. package/src/components/command-bar/list/model.ts +15 -2
  33. package/src/components/command-bar/list/navigation.ts +6 -0
  34. package/src/components/command-bar/list/view.tsx +16 -3
  35. package/src/components/command-bar/pane-templates/workflow.ts +1 -0
  36. package/src/components/command-bar/panel/body.tsx +2 -0
  37. package/src/components/command-bar/panel/index.tsx +3 -0
  38. package/src/components/command-bar/panel/runtime.ts +6 -0
  39. package/src/components/command-bar/panel/types.ts +1 -0
  40. package/src/components/command-bar/routes/root/results.ts +28 -2
  41. package/src/components/command-bar/routes/root/runtime.ts +66 -36
  42. package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
  43. package/src/components/command-bar/routing/navigation-state.ts +17 -0
  44. package/src/components/command-bar/selection-runtime.ts +26 -0
  45. package/src/components/command-bar/surface/assist.test.tsx +276 -0
  46. package/src/components/command-bar/surface/index.tsx +93 -2
  47. package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
  48. package/src/components/command-bar/view-model.ts +7 -1
  49. package/src/components/command-bar/workflow/broker.test.ts +22 -0
  50. package/src/components/command-bar/workflow/broker.ts +1 -0
  51. package/src/components/index.ts +1 -1
  52. package/src/components/layout/detached-pane-shell.tsx +40 -2
  53. package/src/components/layout/floating-pane.tsx +4 -1
  54. package/src/components/layout/pane/header.tsx +64 -6
  55. package/src/components/layout/pane/index.tsx +4 -1
  56. package/src/components/layout/shell/index.tsx +23 -0
  57. package/src/components/layout/shell/pane/layers.tsx +5 -0
  58. package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
  59. package/src/components/onboarding/account-step/form-panel.tsx +157 -0
  60. package/src/components/onboarding/account-step/index.tsx +58 -0
  61. package/src/components/onboarding/account-step/model.test.ts +67 -0
  62. package/src/components/onboarding/account-step/model.ts +126 -0
  63. package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
  64. package/src/components/onboarding/onboarding-steps.tsx +91 -55
  65. package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
  66. package/src/components/onboarding/onboarding-wizard.tsx +93 -9
  67. package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
  68. package/src/components/onboarding/wizard-account.ts +235 -0
  69. package/src/components/onboarding/wizard-keyboard.ts +67 -1
  70. package/src/components/onboarding/wizard-model.ts +2 -2
  71. package/src/components/ui/data-table/index.tsx +1 -0
  72. package/src/components/ui/data-table/opentui/index.tsx +44 -2
  73. package/src/components/ui/data-table/types.ts +10 -0
  74. package/src/components/ui/data-table/visible-range.test.ts +29 -0
  75. package/src/components/ui/data-table/visible-range.ts +21 -0
  76. package/src/components/ui/ui.test.tsx +23 -1
  77. package/src/i18n/es.ts +1093 -0
  78. package/src/i18n/index.test.ts +5 -2
  79. package/src/i18n/index.ts +3 -0
  80. package/src/i18n/ja.ts +66 -3
  81. package/src/i18n/ko.ts +66 -3
  82. package/src/i18n/languages.test.ts +4 -1
  83. package/src/i18n/languages.ts +12 -1
  84. package/src/i18n/zh-cn.ts +66 -3
  85. package/src/i18n/zh-tw.ts +66 -3
  86. package/src/market-data/coordinator/index.ts +1 -1
  87. package/src/market-data/coordinator/quotes.ts +64 -1
  88. package/src/market-data/coordinator/subscriptions.test.ts +72 -0
  89. package/src/market-data/quotes/cadence.ts +2 -0
  90. package/src/plugins/builtin/account-management/footer.ts +9 -3
  91. package/src/plugins/builtin/account-management/model.ts +53 -1
  92. package/src/plugins/builtin/account-management/pane.tsx +107 -24
  93. package/src/plugins/builtin/ai/index.tsx +7 -2
  94. package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
  95. package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
  96. package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
  97. package/src/plugins/builtin/chart-composer/index.tsx +10 -2
  98. package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
  99. package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
  100. package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
  101. package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
  102. package/src/plugins/builtin/cloud/plugin.tsx +9 -1
  103. package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
  104. package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
  105. package/src/plugins/builtin/help/index.tsx +38 -0
  106. package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
  107. package/src/plugins/builtin/options/footer.test.ts +20 -112
  108. package/src/plugins/builtin/options/footer.ts +35 -78
  109. package/src/plugins/builtin/options/index.tsx +6 -0
  110. package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
  111. package/src/plugins/builtin/options/live-quotes.ts +93 -39
  112. package/src/plugins/builtin/options/view.test.tsx +5 -2
  113. package/src/plugins/builtin/options/view.tsx +49 -9
  114. package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
  115. package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
  116. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
  117. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
  118. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
  119. package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
  120. package/src/plugins/builtin/shared/live-streaming.ts +60 -0
  121. package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
  122. package/src/plugins/builtin/shared/plan-access.ts +109 -0
  123. package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
  124. package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
  125. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
  126. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
  127. package/src/plugins/registry/index.test.ts +51 -0
  128. package/src/plugins/registry/index.ts +12 -0
  129. package/src/plugins/registry/pane-settings.ts +29 -0
  130. package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
  131. package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
  132. package/src/renderers/electrobun/view/host/box.tsx +3 -0
  133. package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
  134. package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
  135. package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
  136. package/src/renderers/electrobun/view/host/input.tsx +16 -1
  137. package/src/renderers/electrobun/view/host/mouse.ts +1 -0
  138. package/src/renderers/electrobun/view/host/style.ts +1 -1
  139. package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
  140. package/src/renderers/electrobun/view/main.tsx +2 -0
  141. package/src/renderers/opentui/chart-surface.tsx +2 -0
  142. package/src/renderers/opentui/host.tsx +3 -0
  143. package/src/sources/gloomberb-cloud/index.test.ts +8 -0
  144. package/src/sources/gloomberb-cloud/index.ts +4 -4
  145. package/src/sources/provider-router/history.test.ts +3 -3
  146. package/src/sources/provider-router/history.ts +15 -4
  147. package/src/sources/yahoo-finance/history.test.ts +47 -0
  148. package/src/sources/yahoo-finance/history.ts +5 -1
  149. package/src/state/hooks/quote-streaming.test.tsx +181 -1
  150. package/src/state/hooks/quote-streaming.ts +93 -7
  151. package/src/theme/colors.ts +20 -1
  152. package/src/theme/themes.test.ts +3 -0
  153. package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
  154. package/src/time-series/resolution.test.ts +51 -0
  155. package/src/time-series/resolution.ts +41 -0
  156. package/src/time-series/resolve.test.ts +207 -1
  157. package/src/time-series/resolve.ts +196 -29
  158. package/src/time-series/spec-studies.test.ts +4 -0
  159. package/src/time-series/spec.ts +15 -1
  160. package/src/time-series/types.ts +7 -0
  161. package/src/time-series/use-chart-resolution.test.tsx +37 -0
  162. package/src/time-series/use-chart-resolution.ts +17 -1
  163. package/src/types/plugin.ts +9 -0
  164. package/src/ui/host.tsx +17 -0
@@ -9,6 +9,7 @@ import {
9
9
  type ChartResolutionSupport,
10
10
  type ManualChartResolution,
11
11
  } from "../../time-series/resolution";
12
+ import { repairIsolatedIntradayOhlcOutliers } from "../../time-series/history-quality";
12
13
  import { canonicalExchange } from "../../utils/exchanges";
13
14
  import { resolvePriceHistoryCurrencyUnit } from "../../utils/currency-units";
14
15
  import { isPriceHistoryStaleForCurrentWindow, normalizePriceHistory } from "../../utils/price-history";
@@ -29,7 +30,7 @@ type PriceHistoryCachePolicyKey = Extract<
29
30
  ProviderRouterCachePolicyKey,
30
31
  "priceHistoryIntraday" | "priceHistoryDaily"
31
32
  >;
32
- const PRICE_HISTORY_CACHE_VERSION = 3;
33
+ const PRICE_HISTORY_CACHE_VERSION = 4;
33
34
 
34
35
  interface HistoryRequestDescriptor {
35
36
  identity: RouterRequestIdentity;
@@ -83,6 +84,16 @@ function makeHistoryRequestIdentity(
83
84
  };
84
85
  }
85
86
 
87
+ function normalizeRequestHistory(
88
+ points: PricePoint[],
89
+ request: Pick<HistoryRequestDescriptor, "cachePolicyKey">,
90
+ ): PricePoint[] {
91
+ const normalized = normalizePriceHistory(points);
92
+ return request.cachePolicyKey === "priceHistoryIntraday"
93
+ ? repairIsolatedIntradayOhlcOutliers(normalized)
94
+ : normalized;
95
+ }
96
+
86
97
  export class ProviderRouterHistoryRoutes {
87
98
  constructor(private readonly deps: ProviderRouterCoreDeps) {}
88
99
 
@@ -282,7 +293,7 @@ export class ProviderRouterHistoryRoutes {
282
293
  sourceKeys,
283
294
  false,
284
295
  );
285
- const cachedValue = cached ? normalizePriceHistory(cached.value) : [];
296
+ const cachedValue = cached ? normalizeRequestHistory(cached.value, request) : [];
286
297
  const cachedHistoryStale = request.isCachedValueStale(cachedValue);
287
298
  const forceRefresh = request.context?.cacheMode === "refresh";
288
299
  if (cachedValue.length > 0 && !forceRefresh && cached && !cached.stale && !cachedHistoryStale) {
@@ -308,7 +319,7 @@ export class ProviderRouterHistoryRoutes {
308
319
  return this.firstBrokerResult(candidates, async (candidate) => {
309
320
  const fetched = await request.fetchBroker(candidate);
310
321
  if (fetched === null) return null;
311
- const value = normalizePriceHistory(fetched);
322
+ const value = normalizeRequestHistory(fetched, request);
312
323
  if (request.isFetchedValueStale(value)) return null;
313
324
  this.deps.cacheResource(
314
325
  request.identity.kind,
@@ -326,7 +337,7 @@ export class ProviderRouterHistoryRoutes {
326
337
  return this.firstProviderArrayResult(async (provider) => {
327
338
  const fetched = await request.fetchProvider(provider);
328
339
  if (fetched === null) return null;
329
- const value = normalizePriceHistory(fetched);
340
+ const value = normalizeRequestHistory(fetched, request);
330
341
  if (request.isFetchedValueStale(value)) return null;
331
342
  this.deps.cacheResource(
332
343
  request.identity.kind,
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { loadYahooPriceHistoryForResolution } from "./history";
3
+
4
+ describe("Yahoo chart history", () => {
5
+ test("repairs an isolated intraday wick without dropping the bar", async () => {
6
+ const history = await loadYahooPriceHistoryForResolution({
7
+ ticker: "AMD",
8
+ exchange: "NASDAQ",
9
+ bufferRange: "1M",
10
+ resolution: "15m",
11
+ fetchChart: async () => ({
12
+ meta: { currency: "USD" },
13
+ history: [
14
+ {
15
+ date: new Date("2026-07-06T14:00:00.000Z"),
16
+ open: 99.5,
17
+ high: 101,
18
+ low: 99,
19
+ close: 100,
20
+ },
21
+ {
22
+ date: new Date("2026-07-06T14:15:00.000Z"),
23
+ open: 100,
24
+ high: 150,
25
+ low: 99,
26
+ close: 100.5,
27
+ },
28
+ {
29
+ date: new Date("2026-07-06T14:30:00.000Z"),
30
+ open: 100.5,
31
+ high: 101,
32
+ low: 100,
33
+ close: 100.7,
34
+ },
35
+ ],
36
+ }),
37
+ });
38
+
39
+ expect(history).toHaveLength(3);
40
+ expect(history[1]).toMatchObject({
41
+ open: 100,
42
+ high: 100.5,
43
+ low: 99,
44
+ close: 100.5,
45
+ });
46
+ });
47
+ });
@@ -1,9 +1,11 @@
1
1
  import type { TimeRange } from "../../time-series/range";
2
2
  import {
3
+ isIntradayResolution,
3
4
  normalizeChartResolutionSupport,
4
5
  type ChartResolutionSupport,
5
6
  type ManualChartResolution,
6
7
  } from "../../time-series/resolution";
8
+ import { repairIsolatedIntradayOhlcOutliers } from "../../time-series/history-quality";
7
9
  import type { PricePoint } from "../../types/financials";
8
10
  import { normalizeSubUnitCurrency } from "./mappers";
9
11
  import { getYahooSymbolsToTry } from "./symbols";
@@ -101,7 +103,9 @@ export async function loadYahooPriceHistoryForResolution({
101
103
  }
102
104
  }
103
105
 
104
- return history;
106
+ return isIntradayResolution(resolution)
107
+ ? repairIsolatedIntradayOhlcOutliers(history)
108
+ : history;
105
109
  } catch (err) {
106
110
  lastError = err;
107
111
  }
@@ -2,10 +2,18 @@ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { act, useState } from "react";
3
3
  import { testRender } from "../../renderers/opentui/test-utils";
4
4
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
5
- import { useQuoteStreaming } from "./quote-streaming";
5
+ import { createTestDataProvider } from "../../test-support/data-provider";
6
+ import { AppProvider, PaneInstanceProvider } from "../../state/app/context";
7
+ import { createDefaultConfig } from "../../types/config";
8
+ import { useLiveStreamingSetting } from "../../plugins/builtin/shared/live-streaming";
9
+ import { useLiveQuoteEntries, useQuoteStreaming, useQuoteUpdates } from "./quote-streaming";
6
10
 
7
11
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
8
12
  let bumpHarness: (() => void) | null = null;
13
+ let togglePollingPriority: (() => void) | null = null;
14
+ let updateLiveTargets: ((symbol: string) => void) | null = null;
15
+ let updateFreshnessScope: ((scope: string) => void) | null = null;
16
+ let observedSubscriptionStartedAt = 0;
9
17
 
10
18
  function QuoteStreamingHarness() {
11
19
  const [tick, setTick] = useState(0);
@@ -19,6 +27,53 @@ function QuoteStreamingHarness() {
19
27
  return <text>{String(tick)}</text>;
20
28
  }
21
29
 
30
+ function QuotePollingHarness() {
31
+ useQuoteUpdates([{
32
+ symbol: "AAPL",
33
+ exchange: "NASDAQ",
34
+ }], {
35
+ liveStreaming: false,
36
+ pollIntervalMs: 30,
37
+ });
38
+ return <text>polling</text>;
39
+ }
40
+
41
+ function QuotePollingPriorityHarness() {
42
+ const [selected, setSelected] = useState(false);
43
+ togglePollingPriority = () => setSelected((current) => !current);
44
+ useQuoteUpdates([{
45
+ symbol: "AAPL",
46
+ exchange: "NASDAQ",
47
+ selected,
48
+ weight: selected ? 100 : 10,
49
+ }], {
50
+ liveStreaming: false,
51
+ pollIntervalMs: 1_000,
52
+ });
53
+ return <text>{selected ? "selected" : "idle"}</text>;
54
+ }
55
+
56
+ function FollowedQuoteStreamingHarness() {
57
+ const liveStreaming = useLiveStreamingSetting();
58
+ useQuoteUpdates([{
59
+ symbol: "AMD",
60
+ exchange: "NASDAQ",
61
+ }], { liveStreaming });
62
+ return <text>{liveStreaming ? "live" : "polling"}</text>;
63
+ }
64
+
65
+ function LiveQuoteFreshnessHarness() {
66
+ const [symbol, setSymbol] = useState("AAPL");
67
+ const [freshnessScopeKey, setFreshnessScopeKey] = useState("expiration-1");
68
+ updateLiveTargets = setSymbol;
69
+ updateFreshnessScope = setFreshnessScopeKey;
70
+ observedSubscriptionStartedAt = useLiveQuoteEntries(
71
+ [{ symbol, exchange: "OPTIONS" }],
72
+ { freshnessScopeKey },
73
+ ).subscriptionStartedAt;
74
+ return <text>{symbol}</text>;
75
+ }
76
+
22
77
  afterEach(async () => {
23
78
  if (testSetup) {
24
79
  await act(async () => {
@@ -27,6 +82,10 @@ afterEach(async () => {
27
82
  testSetup = undefined;
28
83
  }
29
84
  bumpHarness = null;
85
+ togglePollingPriority = null;
86
+ updateLiveTargets = null;
87
+ updateFreshnessScope = null;
88
+ observedSubscriptionStartedAt = 0;
30
89
  setSharedMarketDataCoordinator(null);
31
90
  });
32
91
 
@@ -67,4 +126,125 @@ describe("useQuoteStreaming", () => {
67
126
  expect(subscribeCalls).toBe(1);
68
127
  expect(unsubscribeCalls).toBe(0);
69
128
  });
129
+
130
+ test("uses forced polling instead of a subscription when live streaming is disabled", async () => {
131
+ let subscribeCalls = 0;
132
+ let loadCalls = 0;
133
+ const coordinator = {
134
+ subscribeQuotes: () => {
135
+ subscribeCalls += 1;
136
+ return () => {};
137
+ },
138
+ loadQuotesBatch: async (_instruments: unknown[], options: { forceRefresh?: boolean }) => {
139
+ expect(options.forceRefresh).toBe(true);
140
+ loadCalls += 1;
141
+ return [];
142
+ },
143
+ };
144
+ setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
145
+
146
+ testSetup = await testRender(<QuotePollingHarness />, { width: 20, height: 1 });
147
+ await act(async () => testSetup!.renderOnce());
148
+ expect(subscribeCalls).toBe(0);
149
+ expect(loadCalls).toBe(1);
150
+
151
+ await act(async () => {
152
+ await new Promise((resolve) => setTimeout(resolve, 45));
153
+ });
154
+ expect(loadCalls).toBeGreaterThanOrEqual(2);
155
+ });
156
+
157
+ test("inherits disabled streaming from a followed portfolio pane", async () => {
158
+ let subscribeCalls = 0;
159
+ let loadCalls = 0;
160
+ const config = createDefaultConfig("/tmp/gloomberb-live-streaming-test");
161
+ const portfolioPane = config.layout.instances.find((pane) => pane.instanceId === "portfolio-list:main");
162
+ if (!portfolioPane) throw new Error("expected default portfolio pane");
163
+ portfolioPane.settings = { ...portfolioPane.settings, liveStreaming: false };
164
+ setSharedMarketDataCoordinator({
165
+ subscribeQuotes: () => {
166
+ subscribeCalls += 1;
167
+ return () => {};
168
+ },
169
+ loadQuotesBatch: async () => {
170
+ loadCalls += 1;
171
+ return [];
172
+ },
173
+ } as unknown as MarketDataCoordinator);
174
+
175
+ testSetup = await testRender(
176
+ <AppProvider config={config}>
177
+ <PaneInstanceProvider paneId="ticker-detail:main">
178
+ <FollowedQuoteStreamingHarness />
179
+ </PaneInstanceProvider>
180
+ </AppProvider>,
181
+ { width: 20, height: 1 },
182
+ );
183
+ await act(async () => testSetup!.renderOnce());
184
+
185
+ expect(testSetup.captureCharFrame()).toContain("polling");
186
+ expect(subscribeCalls).toBe(0);
187
+ expect(loadCalls).toBe(1);
188
+ });
189
+
190
+ test("does not restart polling when only subscription priority changes", async () => {
191
+ let loadCalls = 0;
192
+ const coordinator = {
193
+ subscribeQuotes: () => () => {},
194
+ loadQuotesBatch: async () => {
195
+ loadCalls += 1;
196
+ return [];
197
+ },
198
+ };
199
+ setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
200
+
201
+ testSetup = await testRender(<QuotePollingPriorityHarness />, { width: 20, height: 1 });
202
+ await act(async () => testSetup!.renderOnce());
203
+ expect(loadCalls).toBe(1);
204
+
205
+ await act(async () => {
206
+ togglePollingPriority?.();
207
+ await Promise.resolve();
208
+ });
209
+ await act(async () => testSetup!.renderOnce());
210
+ expect(loadCalls).toBe(1);
211
+ });
212
+
213
+ test("keeps quote freshness stable while targets move within one surface", async () => {
214
+ const originalDateNow = Date.now;
215
+ let now = 100;
216
+ Date.now = () => now;
217
+ setSharedMarketDataCoordinator(new MarketDataCoordinator(createTestDataProvider({
218
+ subscribeQuotes: () => () => {},
219
+ })));
220
+
221
+ try {
222
+ testSetup = await testRender(<LiveQuoteFreshnessHarness />, {
223
+ width: 20,
224
+ height: 1,
225
+ });
226
+ await act(async () => {
227
+ await testSetup!.renderOnce();
228
+ });
229
+ expect(observedSubscriptionStartedAt).toBe(100);
230
+
231
+ now = 200;
232
+ await act(async () => {
233
+ updateLiveTargets?.("MSFT");
234
+ await Promise.resolve();
235
+ await testSetup!.renderOnce();
236
+ });
237
+ expect(observedSubscriptionStartedAt).toBe(100);
238
+
239
+ now = 300;
240
+ await act(async () => {
241
+ updateFreshnessScope?.("expiration-2");
242
+ await Promise.resolve();
243
+ await testSetup!.renderOnce();
244
+ });
245
+ expect(observedSubscriptionStartedAt).toBe(300);
246
+ } finally {
247
+ Date.now = originalDateNow;
248
+ }
249
+ });
70
250
  });
@@ -11,6 +11,17 @@ import type { QueryEntry } from "../../market-data/result-types";
11
11
  import { buildQuoteKey } from "../../market-data/selectors";
12
12
 
13
13
  const quoteStreamLog = debugLog.createLogger("quote-stream");
14
+ export const DEFAULT_QUOTE_POLL_INTERVAL_MS = 60_000;
15
+
16
+ export interface QuoteStreamingOptions {
17
+ enabled?: boolean;
18
+ }
19
+
20
+ export interface QuoteUpdateOptions {
21
+ liveStreaming?: boolean;
22
+ pollIntervalMs?: number;
23
+ freshnessScopeKey?: string;
24
+ }
14
25
 
15
26
  export function normalizeQuoteStreamSubscriptionTarget(target: QuoteSubscriptionTarget): QuoteSubscriptionTarget | null {
16
27
  const symbol = normalizeSymbol(target.symbol);
@@ -42,7 +53,10 @@ export function buildQuoteStreamSubscriptionKey(target: QuoteSubscriptionTarget)
42
53
  ].join("|");
43
54
  }
44
55
 
45
- export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
56
+ export function useQuoteStreaming(
57
+ targets: QuoteSubscriptionTarget[],
58
+ { enabled = true }: QuoteStreamingOptions = {},
59
+ ): void {
46
60
  const appActive = useAppActive();
47
61
  const coordinator = getSharedMarketDataCoordinator();
48
62
 
@@ -58,9 +72,12 @@ export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
58
72
  const subscriptionKey = sortedEntries.map(([key]) => key).join("|");
59
73
 
60
74
  useEffect(() => {
61
- if (!appActive) {
75
+ if (!enabled || !appActive) {
62
76
  if (normalizedTargets.length > 0) {
63
- quoteStreamLog.info("skipping subscription while inactive", { targets: subscriptionKey });
77
+ quoteStreamLog.info("skipping subscription", {
78
+ reason: enabled ? "inactive" : "disabled",
79
+ targets: subscriptionKey,
80
+ });
64
81
  }
65
82
  return;
66
83
  }
@@ -94,14 +111,81 @@ export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
94
111
  });
95
112
  unsubscribe?.();
96
113
  };
97
- }, [appActive, coordinator, subscriptionKey]);
114
+ }, [appActive, coordinator, enabled, subscriptionKey]);
115
+ }
116
+
117
+ export function useQuoteUpdates(
118
+ targets: QuoteSubscriptionTarget[],
119
+ {
120
+ liveStreaming = true,
121
+ pollIntervalMs = DEFAULT_QUOTE_POLL_INTERVAL_MS,
122
+ }: QuoteUpdateOptions = {},
123
+ ): void {
124
+ const appActive = useAppActive();
125
+ const coordinator = getSharedMarketDataCoordinator();
126
+ const normalizedTargets = targets.flatMap((target) => {
127
+ const normalized = normalizeQuoteStreamSubscriptionTarget(target);
128
+ return normalized ? [normalized] : [];
129
+ });
130
+ const instrumentKey = normalizedTargets
131
+ .map((target) => buildQuoteKey({
132
+ symbol: target.symbol,
133
+ exchange: target.exchange,
134
+ brokerId: target.context?.brokerId,
135
+ brokerInstanceId: target.context?.brokerInstanceId,
136
+ instrument: target.context?.instrument ?? null,
137
+ }))
138
+ .sort()
139
+ .join("\u001f");
140
+ const instruments = useMemo(() => {
141
+ const unique = new Map<string, InstrumentRef>();
142
+ for (const target of normalizedTargets) {
143
+ const instrument: InstrumentRef = {
144
+ symbol: target.symbol,
145
+ exchange: target.exchange,
146
+ brokerId: target.context?.brokerId,
147
+ brokerInstanceId: target.context?.brokerInstanceId,
148
+ instrument: target.context?.instrument ?? null,
149
+ };
150
+ unique.set(buildQuoteKey(instrument), instrument);
151
+ }
152
+ return [...unique.values()];
153
+ }, [instrumentKey]);
154
+
155
+ useQuoteStreaming(targets, { enabled: liveStreaming });
156
+
157
+ useEffect(() => {
158
+ if (liveStreaming || !appActive || !coordinator || instruments.length === 0) return;
159
+ let cancelled = false;
160
+ let inFlight = false;
161
+ const refresh = async () => {
162
+ if (cancelled || inFlight) return;
163
+ inFlight = true;
164
+ try {
165
+ await coordinator.loadQuotesBatch(instruments, { forceRefresh: true });
166
+ } catch {
167
+ // Polling is best effort and the coordinator retains the last good quote.
168
+ } finally {
169
+ inFlight = false;
170
+ }
171
+ };
172
+ void refresh();
173
+ const intervalId = setInterval(() => void refresh(), pollIntervalMs);
174
+ return () => {
175
+ cancelled = true;
176
+ clearInterval(intervalId);
177
+ };
178
+ }, [appActive, coordinator, instrumentKey, instruments, liveStreaming, pollIntervalMs]);
98
179
  }
99
180
 
100
181
  /**
101
182
  * Subscribe to a bounded target set and observe the coordinator entries filled
102
183
  * by that stream. This intentionally avoids per-symbol HTTP quote loads.
103
184
  */
104
- export function useLiveQuoteEntries(targets: QuoteSubscriptionTarget[]): {
185
+ export function useLiveQuoteEntries(
186
+ targets: QuoteSubscriptionTarget[],
187
+ options: QuoteUpdateOptions = {},
188
+ ): {
105
189
  entries: Map<string, QueryEntry<Quote>>;
106
190
  freshnessNow: number;
107
191
  subscriptionStartedAt: number;
@@ -115,7 +199,9 @@ export function useLiveQuoteEntries(targets: QuoteSubscriptionTarget[]): {
115
199
  .map((target) => buildQuoteStreamSubscriptionKey(target))
116
200
  .sort()
117
201
  .join("\u001f");
118
- const subscriptionKey = `${appActive ? "active" : "inactive"}\u001f${targetKey}`;
202
+ const liveStreaming = options.liveStreaming !== false;
203
+ const freshnessKey = options.freshnessScopeKey ?? targetKey;
204
+ const subscriptionKey = `${appActive ? "active" : "inactive"}\u001f${liveStreaming ? "live" : "poll"}\u001f${freshnessKey}`;
119
205
  const subscriptionRef = useRef({
120
206
  key: subscriptionKey,
121
207
  startedAt: Date.now(),
@@ -149,7 +235,7 @@ export function useLiveQuoteEntries(targets: QuoteSubscriptionTarget[]): {
149
235
  return () => clearInterval(interval);
150
236
  }, [appActive, targetKey]);
151
237
 
152
- useQuoteStreaming(targets);
238
+ useQuoteUpdates(targets, options);
153
239
  return {
154
240
  entries: useQuoteEntries(instruments),
155
241
  freshnessNow,
@@ -1,4 +1,4 @@
1
- import { blendForContrast, blendHex, higherContrast } from "./color-utils";
1
+ import { blendForContrast, blendForContrastOnSurfaces, blendHex, higherContrast } from "./color-utils";
2
2
  import { getTheme, DEFAULT_THEME, type Theme } from "./themes";
3
3
 
4
4
  export type ThemeColors = Readonly<Omit<Theme, "name" | "description">>;
@@ -8,6 +8,9 @@ export function getThemeColors(id: string): ThemeColors {
8
8
  return Object.freeze(palette);
9
9
  }
10
10
 
11
+ /** Soft violet the AI section is tinted with, before per-theme contrast fixup. */
12
+ const ASSIST_ACCENT = "#a877ff";
13
+
11
14
  let currentColors = getThemeColors(DEFAULT_THEME);
12
15
 
13
16
  /** Compatibility facade for non-React formatting and rendering helpers. */
@@ -174,6 +177,22 @@ export function commandBarSubtleText(palette: ThemeColors = colors): string {
174
177
  return blendForContrast(palette.textDim, base, fallback, 4.1);
175
178
  }
176
179
 
180
+ /**
181
+ * Accent for the command bar's AI section. No theme ships a violet token, so the
182
+ * hue is fixed — that is the point, it has to read as "not one of your results"
183
+ * on monochrome palettes too — and only darkened or lightened until it clears
184
+ * the subtle-text contrast floor on both command bar surfaces.
185
+ */
186
+ export function commandBarAccentText(palette: ThemeColors = colors): string {
187
+ const surfaces = [commandBarBg(palette), commandBarPanelBg(palette)] as const;
188
+ return blendForContrastOnSurfaces(
189
+ ASSIST_ACCENT,
190
+ surfaces,
191
+ higherContrast("#000000", "#ffffff", surfaces[0]),
192
+ 3.6,
193
+ );
194
+ }
195
+
177
196
  export function commandBarSelectedText(palette: ThemeColors = colors): string {
178
197
  const base = commandBarSelectedBg(palette);
179
198
  const preferred = higherContrast(palette.selectedText, palette.text, base);
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, test } from "bun:test";
2
2
  import {
3
3
  applyTheme,
4
+ commandBarAccentText,
4
5
  commandBarBg,
5
6
  commandBarHeadingText,
6
7
  commandBarPanelBg,
@@ -65,6 +66,8 @@ describe("theme contrast", () => {
65
66
  assertMinContrast(id, "commandBarText/bg", commandBarText(), commandBarBg(), BODY_TEXT_MIN);
66
67
  assertMinContrast(id, "commandBarHeadingText/bg", commandBarHeadingText(), commandBarBg(), SUBTLE_TEXT_MIN);
67
68
  assertMinContrast(id, "commandBarSubtleText/panel", commandBarSubtleText(), commandBarPanelBg(), SUBTLE_TEXT_MIN);
69
+ assertMinContrast(id, "commandBarAccentText/bg", commandBarAccentText(), commandBarBg(), SUBTLE_TEXT_MIN);
70
+ assertMinContrast(id, "commandBarAccentText/panel", commandBarAccentText(), commandBarPanelBg(), SUBTLE_TEXT_MIN);
68
71
  assertMinContrast(id, "commandBarSelectedText/selected", commandBarSelectedText(), commandBarSelectedBg(), BODY_TEXT_MIN);
69
72
  }
70
73
  });
@@ -1,5 +1,5 @@
1
- import type { PricePoint } from "../../types/financials";
2
- import { getPricePointTimestamp } from "../../utils/price-history";
1
+ import type { PricePoint } from "../types/financials";
2
+ import { getPricePointTimestamp } from "../utils/price-history";
3
3
 
4
4
  const MAX_NEIGHBOR_GAP_MS = 60 * 60 * 1000;
5
5
  const STABLE_NEIGHBOR_CLOSE_RATIO = 0.01;
@@ -34,7 +34,7 @@ function isIsolatedOhlcOutlier(
34
34
  ));
35
35
  }
36
36
 
37
- export function hasMalformedCloudIntradayHistory(points: readonly PricePoint[]): boolean {
37
+ export function hasMalformedIntradayHistory(points: readonly PricePoint[]): boolean {
38
38
  for (let index = 1; index < points.length - 1; index += 1) {
39
39
  if (isIsolatedOhlcOutlier(points[index - 1]!, points[index]!, points[index + 1]!)) {
40
40
  return true;
@@ -42,3 +42,27 @@ export function hasMalformedCloudIntradayHistory(points: readonly PricePoint[]):
42
42
  }
43
43
  return false;
44
44
  }
45
+
46
+ export function repairIsolatedIntradayOhlcOutliers(
47
+ points: readonly PricePoint[],
48
+ ): PricePoint[] {
49
+ return points.map((point, index) => {
50
+ const previous = points[index - 1];
51
+ const next = points[index + 1];
52
+ if (!previous || !next || !isIsolatedOhlcOutlier(previous, point, next)) return point;
53
+
54
+ const isOutlier = (value: number | undefined) => (
55
+ typeof value === "number"
56
+ && Number.isFinite(value)
57
+ && relativeDifference(value, point.close) > ISOLATED_OHLC_OUTLIER_RATIO
58
+ );
59
+ const open = isOutlier(point.open) ? previous.close : point.open;
60
+ const bodyOpen = open ?? point.close;
61
+ return {
62
+ ...point,
63
+ open,
64
+ high: isOutlier(point.high) ? Math.max(bodyOpen, point.close) : point.high,
65
+ low: isOutlier(point.low) ? Math.min(bodyOpen, point.close) : point.low,
66
+ };
67
+ });
68
+ }
@@ -4,10 +4,12 @@ import {
4
4
  getBestSupportedResolutionForVisibleWindow,
5
5
  getExpandedBufferRange,
6
6
  getPresetResolution,
7
+ getSupportedChartResolutionsForViewport,
7
8
  isRangePresetSupported,
8
9
  normalizeChartResolution,
9
10
  sortChartResolutions,
10
11
  } from "./resolution";
12
+ import { TIME_RANGES } from "./range";
11
13
 
12
14
  describe("chart-resolution", () => {
13
15
  test("maps range presets to their default manual resolutions", () => {
@@ -79,4 +81,53 @@ describe("chart-resolution", () => {
79
81
 
80
82
  expect(CHART_RESOLUTION_STEP_MS["1h"]).toBe(60 * 60_000);
81
83
  });
84
+
85
+ test("exposes only intervals that can cover and chart the authored viewport", () => {
86
+ const support = [
87
+ { resolution: "1m", maxRange: "1W" },
88
+ { resolution: "15m", maxRange: "3M" },
89
+ { resolution: "30m", maxRange: "6M" },
90
+ { resolution: "1h", maxRange: "1Y" },
91
+ { resolution: "1d", maxRange: "ALL" },
92
+ ] as const;
93
+
94
+ expect(getSupportedChartResolutionsForViewport("1M", support))
95
+ .toEqual(["15m", "30m", "1h", "1d"]);
96
+ expect(getSupportedChartResolutionsForViewport("1Y", support))
97
+ .toEqual(["1h", "1d"]);
98
+ expect(getSupportedChartResolutionsForViewport("1M", support, {
99
+ start: "2024-01-01",
100
+ end: "2026-01-01",
101
+ })).toEqual(["1d"]);
102
+ });
103
+
104
+ test("hides intervals too coarse to form a chart for the selected range", () => {
105
+ const support = (Object.keys(CHART_RESOLUTION_STEP_MS) as Array<
106
+ keyof typeof CHART_RESOLUTION_STEP_MS
107
+ >).map((resolution) => ({ resolution, maxRange: "ALL" as const }));
108
+
109
+ expect(getSupportedChartResolutionsForViewport("1D", support))
110
+ .toEqual(["1m", "5m", "15m", "30m", "45m", "1h"]);
111
+ expect(getSupportedChartResolutionsForViewport("1W", support))
112
+ .toEqual(["1m", "5m", "15m", "30m", "45m", "1h", "1d"]);
113
+ expect(getSupportedChartResolutionsForViewport("1M", support))
114
+ .toEqual(["1m", "5m", "15m", "30m", "45m", "1h", "1d", "1wk"]);
115
+ expect(getSupportedChartResolutionsForViewport("3M", support))
116
+ .toEqual(Object.keys(CHART_RESOLUTION_STEP_MS));
117
+ });
118
+
119
+ test("evaluates every range and resolution capability boundary", () => {
120
+ const resolutions = ["1m", "5m", "15m", "30m", "45m", "1h"] as const;
121
+ const support = resolutions.map((resolution, index) => ({
122
+ resolution,
123
+ maxRange: TIME_RANGES[Math.min(index, TIME_RANGES.length - 1)]!,
124
+ }));
125
+
126
+ for (const [rangeIndex, range] of TIME_RANGES.entries()) {
127
+ const available = new Set(getSupportedChartResolutionsForViewport(range, support));
128
+ for (const [resolutionIndex, resolution] of resolutions.entries()) {
129
+ expect(available.has(resolution)).toBe(rangeIndex <= resolutionIndex);
130
+ }
131
+ }
132
+ });
82
133
  });