gloomberb 0.3.7 → 0.4.1

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 (143) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +45 -69
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +363 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +4 -2
  37. package/src/components/layout/header.tsx +3 -3
  38. package/src/components/layout/pane-header.tsx +26 -2
  39. package/src/components/layout/pane-sizing.ts +6 -0
  40. package/src/components/layout/pane.tsx +5 -1
  41. package/src/components/layout/shell.test.tsx +77 -5
  42. package/src/components/layout/shell.tsx +67 -52
  43. package/src/components/layout/status-bar.test.tsx +66 -0
  44. package/src/components/layout/status-bar.tsx +26 -5
  45. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  46. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  47. package/src/components/ticker-list-table.test.tsx +83 -0
  48. package/src/components/ticker-list-table.tsx +181 -97
  49. package/src/components/ui/data-table.tsx +50 -5
  50. package/src/components/ui/index.ts +1 -0
  51. package/src/components/ui/page-stack-view.tsx +60 -0
  52. package/src/components/use-double-click-activation.ts +31 -0
  53. package/src/index.tsx +10 -10
  54. package/src/market-data/coordinator.test.ts +92 -4
  55. package/src/market-data/coordinator.ts +32 -5
  56. package/src/market-data/hooks.test.tsx +120 -0
  57. package/src/market-data/hooks.tsx +53 -16
  58. package/src/market-data/request-types.ts +4 -2
  59. package/src/market-data/selectors.ts +24 -58
  60. package/src/plugins/builtin/chat-controller.test.ts +400 -3
  61. package/src/plugins/builtin/chat-controller.ts +328 -35
  62. package/src/plugins/builtin/chat.test.tsx +118 -6
  63. package/src/plugins/builtin/chat.tsx +180 -116
  64. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  65. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  66. package/src/plugins/builtin/layout-manager.tsx +9 -0
  67. package/src/plugins/builtin/news.tsx +1 -11
  68. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  69. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  70. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  71. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  72. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  73. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  74. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  75. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  76. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  77. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  78. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  79. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  80. package/src/plugins/builtin/sec.tsx +1 -11
  81. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  82. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  83. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  84. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  85. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  86. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  87. package/src/plugins/catalog.ts +65 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  89. package/src/plugins/ibkr/gateway-service.test.ts +67 -1
  90. package/src/plugins/ibkr/gateway-service.ts +147 -7
  91. package/src/plugins/ibkr/index.tsx +7 -1
  92. package/src/plugins/pane-manager.test.ts +1 -1
  93. package/src/plugins/plugin-runtime.tsx +27 -7
  94. package/src/plugins/prediction-markets/cli.ts +28 -0
  95. package/src/plugins/prediction-markets/controller-data.ts +132 -17
  96. package/src/plugins/prediction-markets/controller.ts +142 -122
  97. package/src/plugins/prediction-markets/index.tsx +7 -1
  98. package/src/plugins/prediction-markets/launch.ts +51 -16
  99. package/src/plugins/prediction-markets/metrics.ts +9 -5
  100. package/src/plugins/prediction-markets/pane.test.tsx +197 -57
  101. package/src/plugins/prediction-markets/pane.tsx +80 -155
  102. package/src/plugins/prediction-markets/services/fetch.ts +4 -0
  103. package/src/plugins/prediction-markets/table.tsx +6 -3
  104. package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/plugins/registry.ts +23 -0
  107. package/src/sources/gloomberb-cloud.test.ts +29 -0
  108. package/src/sources/gloomberb-cloud.ts +89 -1
  109. package/src/sources/provider-router.test.ts +265 -3
  110. package/src/sources/provider-router.ts +239 -11
  111. package/src/sources/yahoo-finance.test.ts +26 -0
  112. package/src/sources/yahoo-finance.ts +54 -3
  113. package/src/state/app-context-selector.test.tsx +99 -0
  114. package/src/state/app-context.test.ts +18 -0
  115. package/src/state/app-context.tsx +170 -65
  116. package/src/state/selectors.ts +25 -0
  117. package/src/theme/colors.ts +8 -8
  118. package/src/theme/themes.test.ts +2 -0
  119. package/src/theme/themes.ts +22 -10
  120. package/src/types/broker.ts +21 -0
  121. package/src/types/config.ts +17 -1
  122. package/src/types/data-provider.ts +18 -0
  123. package/src/types/financials.ts +31 -1
  124. package/src/types/plugin.ts +70 -0
  125. package/src/updater.test.ts +2 -0
  126. package/src/utils/api-client.test.ts +57 -0
  127. package/src/utils/api-client.ts +47 -12
  128. package/src/utils/back-navigation.ts +27 -0
  129. package/src/utils/format.test.ts +14 -0
  130. package/src/utils/format.ts +3 -3
  131. package/src/utils/market-status.ts +46 -1
  132. package/src/utils/quote-freshness.ts +127 -0
  133. package/src/utils/quote-resolution.test.ts +277 -0
  134. package/src/utils/quote-resolution.ts +641 -0
  135. package/src/utils/slugify.ts +3 -0
  136. package/src/utils/timestamp.ts +25 -0
  137. package/src/version.ts +1 -1
  138. package/src/cli/commands/portfolio.ts +0 -210
  139. package/src/cli/commands/watchlist.ts +0 -210
  140. package/src/plugins/builtin/data-providers.ts +0 -16
  141. package/src/sources/exchange-rates.ts +0 -18
  142. package/src/utils/ascii-chart.ts +0 -172
  143. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -0,0 +1,60 @@
1
+ import { useKeyboard } from "@opentui/react";
2
+ import type { ReactNode } from "react";
3
+ import { colors } from "../../theme/colors";
4
+ import { isBackNavigationKey } from "../../utils/back-navigation";
5
+
6
+ interface PageStackViewProps {
7
+ focused: boolean;
8
+ detailOpen: boolean;
9
+ onBack: () => void;
10
+ rootContent: ReactNode;
11
+ detailContent: ReactNode;
12
+ backLabel?: string;
13
+ backHint?: string;
14
+ }
15
+
16
+ export function PageStackView({
17
+ focused,
18
+ detailOpen,
19
+ onBack,
20
+ rootContent,
21
+ detailContent,
22
+ backLabel = "Back",
23
+ backHint,
24
+ }: PageStackViewProps) {
25
+ useKeyboard((event) => {
26
+ if (!focused || !detailOpen || !isBackNavigationKey(event)) return;
27
+ event.stopPropagation?.();
28
+ event.preventDefault?.();
29
+ onBack();
30
+ });
31
+
32
+ if (!detailOpen) {
33
+ return (
34
+ <box flexDirection="column" flexGrow={1}>
35
+ {rootContent}
36
+ </box>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <box flexDirection="column" flexGrow={1}>
42
+ <box height={1} flexDirection="row">
43
+ <box
44
+ onMouseDown={(event) => {
45
+ event.preventDefault?.();
46
+ event.stopPropagation?.();
47
+ onBack();
48
+ }}
49
+ >
50
+ <text fg={colors.textBright}>{`<- ${backLabel}`}</text>
51
+ </box>
52
+ <box flexGrow={1} />
53
+ {backHint ? <text fg={colors.textMuted}>{backHint}</text> : null}
54
+ </box>
55
+ <box flexDirection="column" flexGrow={1}>
56
+ {detailContent}
57
+ </box>
58
+ </box>
59
+ );
60
+ }
@@ -0,0 +1,31 @@
1
+ import { useCallback, useRef } from "react";
2
+
3
+ interface LastClickState {
4
+ targetKey: string;
5
+ }
6
+
7
+ export function useDoubleClickActivation<T>({
8
+ onSelect,
9
+ onActivate,
10
+ }: {
11
+ onSelect?: (value: T) => void;
12
+ onActivate?: (value: T) => void;
13
+ }) {
14
+ const lastClickRef = useRef<LastClickState | null>(null);
15
+
16
+ return useCallback((targetKey: string, value: T) => {
17
+ const lastClick = lastClickRef.current;
18
+
19
+ onSelect?.(value);
20
+
21
+ if (lastClick && lastClick.targetKey === targetKey) {
22
+ lastClickRef.current = null;
23
+ onActivate?.(value);
24
+ return;
25
+ }
26
+
27
+ lastClickRef.current = {
28
+ targetKey,
29
+ };
30
+ }, [onActivate, onSelect]);
31
+ }
package/src/index.tsx CHANGED
@@ -4,10 +4,9 @@ import { App } from "./app";
4
4
  import { getDataDir, initDataDir } from "./data/config-store";
5
5
  import { join } from "path";
6
6
  import { existsSync, mkdirSync } from "fs";
7
- import { runCli } from "./cli/index";
7
+ import { dispatchCli } from "./cli/index";
8
8
  import { loadExternalPlugins } from "./plugins/loader";
9
9
  import { debugLog } from "./utils/debug-log";
10
- import { parsePredictionLaunchArgs } from "./plugins/prediction-markets/launch";
11
10
 
12
11
  async function main() {
13
12
  // Intercept console.log/warn/error to capture in debug log
@@ -18,10 +17,14 @@ async function main() {
18
17
 
19
18
  // Handle CLI subcommands (install, remove, update, plugins)
20
19
  const cliArgs = process.argv.slice(2);
21
- const predictionLaunchIntent = parsePredictionLaunchArgs(cliArgs);
22
- if (cliArgs.length > 0 && !predictionLaunchIntent) {
23
- const handled = await runCli(cliArgs);
24
- if (handled) return;
20
+ const externalPlugins = await loadExternalPlugins();
21
+ let cliLaunchRequest = null;
22
+ if (cliArgs.length > 0) {
23
+ const dispatchResult = await dispatchCli(cliArgs, { externalPlugins });
24
+ if (dispatchResult.kind === "handled") return;
25
+ if (dispatchResult.kind === "launch-ui") {
26
+ cliLaunchRequest = dispatchResult.request;
27
+ }
25
28
  }
26
29
 
27
30
  // Determine data directory
@@ -40,9 +43,6 @@ async function main() {
40
43
  // Load or create config
41
44
  const config = await initDataDir(dataDir);
42
45
 
43
- // Load external plugins from ~/.gloomberb/plugins/
44
- const externalPlugins = await loadExternalPlugins();
45
-
46
46
  // Create renderer
47
47
  const renderer = await createCliRenderer({
48
48
  exitOnCtrlC: true,
@@ -56,7 +56,7 @@ async function main() {
56
56
  config={config}
57
57
  renderer={renderer}
58
58
  externalPlugins={externalPlugins}
59
- predictionLaunchIntent={predictionLaunchIntent}
59
+ cliLaunchRequest={cliLaunchRequest}
60
60
  />
61
61
  );
62
62
  }
@@ -80,8 +80,8 @@ describe("MarketDataCoordinator", () => {
80
80
  const coordinator = new MarketDataCoordinator(provider);
81
81
  const request = {
82
82
  instrument: { symbol: "AAPL", exchange: "NASDAQ" },
83
- range: "1Y" as const,
84
- granularity: "daily" as const,
83
+ bufferRange: "1Y" as const,
84
+ granularity: "range" as const,
85
85
  };
86
86
 
87
87
  const first = await coordinator.loadChart(request);
@@ -103,8 +103,8 @@ describe("MarketDataCoordinator", () => {
103
103
  const coordinator = new MarketDataCoordinator(provider);
104
104
  const request = {
105
105
  instrument: { symbol: "AAPL", exchange: "NASDAQ" },
106
- range: "1Y" as const,
107
- granularity: "daily" as const,
106
+ bufferRange: "1Y" as const,
107
+ granularity: "range" as const,
108
108
  };
109
109
 
110
110
  const entry = await coordinator.loadChart(request);
@@ -142,6 +142,30 @@ describe("MarketDataCoordinator", () => {
142
142
  expect(coordinator.getQuoteEntry(instrument).data?.price).toBe(412.5);
143
143
  });
144
144
 
145
+ it("routes manual-resolution chart requests through the resolution-aware provider path", async () => {
146
+ let requested: { range: string; resolution: string } | null = null;
147
+ const provider = createProvider({
148
+ getPriceHistoryForResolution: async (_symbol, _exchange, range, resolution) => {
149
+ requested = { range, resolution };
150
+ return [
151
+ { date: new Date("2024-01-01"), close: 100 },
152
+ { date: new Date("2024-01-02"), close: 101 },
153
+ ];
154
+ },
155
+ });
156
+ const coordinator = new MarketDataCoordinator(provider);
157
+
158
+ const entry = await coordinator.loadChart({
159
+ instrument: { symbol: "AAPL", exchange: "NASDAQ" },
160
+ bufferRange: "1Y",
161
+ granularity: "resolution",
162
+ resolution: "1d",
163
+ });
164
+
165
+ expect(requested).toEqual({ range: "1Y", resolution: "1d" });
166
+ expect(entry.data?.length).toBe(2);
167
+ });
168
+
145
169
  it("projects live quote updates into premarket display fields when the stream lacks explicit ext-hours fields", async () => {
146
170
  let streamed: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
147
171
  const provider = createProvider({
@@ -192,6 +216,7 @@ describe("MarketDataCoordinator", () => {
192
216
  change: 2.5,
193
217
  changePercent: 2.48,
194
218
  lastUpdated: Date.now(),
219
+ dataSource: "live",
195
220
  },
196
221
  );
197
222
 
@@ -287,6 +312,69 @@ describe("MarketDataCoordinator", () => {
287
312
  expect(coordinator.getTickerFinancialsSync(instrument)?.quote?.price).toBe(0.245);
288
313
  });
289
314
 
315
+ it("keeps the fresh snapshot quote when a stale cloud stream update arrives later", async () => {
316
+ const fixedNow = Date.parse("2026-04-08T10:30:00Z");
317
+ const realDateNow = Date.now;
318
+ Date.now = () => fixedNow;
319
+ try {
320
+ let streamed: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
321
+ const provider = createProvider({
322
+ getTickerFinancials: async () => ({
323
+ quote: {
324
+ symbol: "HY9H",
325
+ providerId: "yahoo",
326
+ dataSource: "yahoo",
327
+ price: 598,
328
+ currency: "EUR",
329
+ change: 6,
330
+ changePercent: 1.01,
331
+ lastUpdated: Date.parse("2026-04-08T10:25:00Z"),
332
+ listingExchangeName: "FWB2",
333
+ marketState: "REGULAR",
334
+ sessionConfidence: "derived",
335
+ },
336
+ annualStatements: [],
337
+ quarterlyStatements: [],
338
+ priceHistory: [],
339
+ }),
340
+ subscribeQuotes: (_targets, onQuote) => {
341
+ streamed = onQuote as typeof streamed;
342
+ return () => {};
343
+ },
344
+ });
345
+ const coordinator = new MarketDataCoordinator(provider);
346
+ const instrument = { symbol: "HY9H", exchange: "FWB2" };
347
+
348
+ await coordinator.loadSnapshot(instrument);
349
+ coordinator.subscribeQuotes([{ instrument }]);
350
+ const onStreamed = streamed;
351
+ if (!onStreamed) throw new Error("expected streaming callback");
352
+ onStreamed(
353
+ { symbol: "HY9H", exchange: "FWB2" },
354
+ {
355
+ symbol: "HY9H",
356
+ providerId: "gloomberb-cloud",
357
+ dataSource: "delayed",
358
+ price: 528,
359
+ currency: "EUR",
360
+ change: 4,
361
+ changePercent: 0.76,
362
+ lastUpdated: Date.parse("2026-04-07T17:55:00Z"),
363
+ listingExchangeName: "FWB2",
364
+ marketState: "REGULAR",
365
+ sessionConfidence: "explicit",
366
+ },
367
+ );
368
+
369
+ expect(coordinator.getQuoteEntry(instrument).data).toBeNull();
370
+ expect(coordinator.getQuoteEntry(instrument).lastGoodData?.price).toBe(598);
371
+ expect(coordinator.getTickerFinancialsSync(instrument)?.quote?.price).toBe(598);
372
+ expect(coordinator.getTickerFinancialsSync(instrument)?.quote?.providerId).toBe("yahoo");
373
+ } finally {
374
+ Date.now = realDateNow;
375
+ }
376
+ });
377
+
290
378
  it("hydrates ticker financials synchronously from primed cached data", () => {
291
379
  const coordinator = new MarketDataCoordinator(createProvider());
292
380
  const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
@@ -23,6 +23,7 @@ import {
23
23
  } from "./selectors";
24
24
  import { traceMarketData } from "./trace";
25
25
  import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
26
+ import { hasFreshQuoteForCurrentSession, isQuoteStaleForCurrentSession } from "../utils/quote-freshness";
26
27
 
27
28
  const EMPTY_MESSAGE = "No data available";
28
29
  const EXPECTED_EMPTY = /no data|not found|delisted|unavailable|unsupported/i;
@@ -30,8 +31,8 @@ const EXPECTED_EMPTY = /no data|not found|delisted|unavailable|unsupported/i;
30
31
  function createBaselineChartRequest(instrument: InstrumentRef): ChartRequest {
31
32
  return {
32
33
  instrument,
33
- range: "5Y",
34
- granularity: "daily",
34
+ bufferRange: "5Y",
35
+ granularity: "range",
35
36
  };
36
37
  }
37
38
 
@@ -92,6 +93,21 @@ function readyEntry<T>(
92
93
  };
93
94
  }
94
95
 
96
+ function readyQuoteEntry(
97
+ current: QueryEntry<Quote>,
98
+ quote: Quote,
99
+ source: string,
100
+ attempts: ProviderAttempt[],
101
+ ): QueryEntry<Quote> {
102
+ if (
103
+ isQuoteStaleForCurrentSession(quote)
104
+ && hasFreshQuoteForCurrentSession([current.data, current.lastGoodData])
105
+ ) {
106
+ return readyEntry(current, null, current.source ?? source, attempts, { keepLastGoodOnEmpty: true });
107
+ }
108
+ return readyEntry(current, quote, source, attempts, { keepLastGoodOnEmpty: true });
109
+ }
110
+
95
111
  function errorEntry<T>(current: QueryEntry<T>, attempt: ProviderAttempt): QueryEntry<T> {
96
112
  return {
97
113
  ...current,
@@ -349,7 +365,7 @@ export class MarketDataCoordinator {
349
365
  );
350
366
  const source = quote.providerId ?? this.dataProvider.id;
351
367
  const attempts = [createAttempt(source, startedAt, "success")];
352
- return this.quoteStore.update(key, (current) => readyEntry(current, quote, source, attempts, { keepLastGoodOnEmpty: true }));
368
+ return this.quoteStore.update(key, (current) => readyQuoteEntry(current, quote, source, attempts));
353
369
  } catch (error) {
354
370
  const classified = classifyError(error);
355
371
  const attempt = createAttempt(this.dataProvider.id, startedAt, EXPECTED_EMPTY.test(classified.message) ? "empty" : "fatal_error", classified.reasonCode, classified.message);
@@ -381,10 +397,21 @@ export class MarketDataCoordinator {
381
397
  cacheMode: options.forceRefresh ? "refresh" : "default",
382
398
  },
383
399
  )
400
+ : request.granularity === "resolution" && request.resolution && this.dataProvider.getPriceHistoryForResolution
401
+ ? await this.dataProvider.getPriceHistoryForResolution(
402
+ request.instrument.symbol,
403
+ request.instrument.exchange ?? "",
404
+ request.bufferRange,
405
+ request.resolution,
406
+ {
407
+ ...toMarketDataContext(request.instrument),
408
+ cacheMode: options.forceRefresh ? "refresh" : "default",
409
+ },
410
+ )
384
411
  : await this.dataProvider.getPriceHistory(
385
412
  request.instrument.symbol,
386
413
  request.instrument.exchange ?? "",
387
- request.range,
414
+ request.bufferRange,
388
415
  {
389
416
  ...toMarketDataContext(request.instrument),
390
417
  cacheMode: options.forceRefresh ? "refresh" : "default",
@@ -555,7 +582,7 @@ export class MarketDataCoordinator {
555
582
  const key = buildQuoteKey(instrument);
556
583
  const current = this.quoteStore.get(key);
557
584
  const attempts = [createAttempt(quote.providerId ?? this.dataProvider.id, Date.now(), "success")];
558
- this.quoteStore.set(key, readyEntry(current, quote, quote.providerId ?? this.dataProvider.id, attempts, { keepLastGoodOnEmpty: true }));
585
+ this.quoteStore.set(key, readyQuoteEntry(current, quote, quote.providerId ?? this.dataProvider.id, attempts));
559
586
  });
560
587
  }
561
588
  }
@@ -0,0 +1,120 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, useState } from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import type { TickerFinancials } from "../types/financials";
5
+ import type { TickerRecord } from "../types/ticker";
6
+ import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "./coordinator";
7
+ import { useFxRatesMap, useTickerFinancialsMap } from "./hooks";
8
+
9
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
10
+ let bumpHarness: (() => void) | null = null;
11
+ let latestFxRates: Map<string, number> | null = null;
12
+ let latestFinancialsMap: Map<string, TickerFinancials> | null = null;
13
+
14
+ const readyUsdEntry = {
15
+ phase: "ready" as const,
16
+ data: 1,
17
+ lastGoodData: 1,
18
+ source: "test",
19
+ fetchedAt: 1,
20
+ staleAt: null,
21
+ error: null,
22
+ attempts: [],
23
+ };
24
+ const readyEurEntry = {
25
+ phase: "ready" as const,
26
+ data: 1.08,
27
+ lastGoodData: 1.08,
28
+ source: "test",
29
+ fetchedAt: 1,
30
+ staleAt: null,
31
+ error: null,
32
+ attempts: [],
33
+ };
34
+ const sampleFinancials: TickerFinancials = {
35
+ annualStatements: [],
36
+ quarterlyStatements: [],
37
+ priceHistory: [],
38
+ quote: {
39
+ symbol: "SAP",
40
+ price: 250,
41
+ currency: "EUR",
42
+ change: 2,
43
+ changePercent: 0.8,
44
+ lastUpdated: 1,
45
+ },
46
+ };
47
+ const tickers: TickerRecord[] = [
48
+ {
49
+ metadata: {
50
+ ticker: "SAP",
51
+ exchange: "XETRA",
52
+ currency: "EUR",
53
+ name: "SAP",
54
+ portfolios: [],
55
+ watchlists: [],
56
+ positions: [],
57
+ custom: {},
58
+ tags: [],
59
+ },
60
+ },
61
+ ];
62
+
63
+ function HooksHarness() {
64
+ const [tick, setTick] = useState(0);
65
+ bumpHarness = () => setTick((current) => current + 1);
66
+
67
+ latestFxRates = useFxRatesMap(["USD", "EUR"]);
68
+ latestFinancialsMap = useTickerFinancialsMap(tickers);
69
+
70
+ return <text>{String(tick)}</text>;
71
+ }
72
+
73
+ afterEach(async () => {
74
+ if (testSetup) {
75
+ await act(async () => {
76
+ testSetup!.renderer.destroy();
77
+ });
78
+ testSetup = undefined;
79
+ }
80
+ bumpHarness = null;
81
+ latestFxRates = null;
82
+ latestFinancialsMap = null;
83
+ setSharedMarketDataCoordinator(null);
84
+ });
85
+
86
+ describe("market-data hooks", () => {
87
+ test("preserve derived map instances across unrelated rerenders", async () => {
88
+ const coordinator = {
89
+ subscribe: () => () => {},
90
+ getVersion: () => 1,
91
+ getFxEntry: (currency: string) => (currency === "EUR" ? readyEurEntry : readyUsdEntry),
92
+ loadFxRate: async () => {},
93
+ getTickerFinancialsSync: () => sampleFinancials,
94
+ };
95
+ setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
96
+
97
+ testSetup = await testRender(<HooksHarness />, {
98
+ width: 20,
99
+ height: 1,
100
+ });
101
+
102
+ await act(async () => {
103
+ await testSetup!.renderOnce();
104
+ });
105
+
106
+ const initialFxRates = latestFxRates;
107
+ const initialFinancialsMap = latestFinancialsMap;
108
+
109
+ await act(async () => {
110
+ bumpHarness?.();
111
+ await Promise.resolve();
112
+ });
113
+ await act(async () => {
114
+ await testSetup!.renderOnce();
115
+ });
116
+
117
+ expect(latestFxRates).toBe(initialFxRates);
118
+ expect(latestFinancialsMap).toBe(initialFinancialsMap);
119
+ });
120
+ });
@@ -9,6 +9,7 @@ import {
9
9
  resolveEntryValue,
10
10
  } from "./coordinator";
11
11
  import type { QueryEntry } from "./result-types";
12
+ import { buildChartKey } from "./selectors";
12
13
 
13
14
  function useCoordinatorVersion(): number {
14
15
  const coordinator = getSharedMarketDataCoordinator();
@@ -33,6 +34,21 @@ function stableCurrencyList(currencies: Array<string | null | undefined>): strin
33
34
  )].sort((left, right) => left.localeCompare(right));
34
35
  }
35
36
 
37
+ function buildTickerFinancialsMapKey(tickers: TickerRecord[]): string {
38
+ return tickers.map((ticker) => {
39
+ const instrument = ticker.metadata.broker_contracts?.[0];
40
+ return [
41
+ ticker.metadata.ticker,
42
+ ticker.metadata.exchange ?? "",
43
+ instrument?.brokerId ?? "",
44
+ instrument?.brokerInstanceId ?? "",
45
+ instrument?.conId ?? "",
46
+ instrument?.localSymbol ?? "",
47
+ instrument?.symbol ?? "",
48
+ ].join("|");
49
+ }).join("::");
50
+ }
51
+
36
52
  export function useTickerInstrument(symbol: string | null | undefined, ticker: TickerRecord | null | undefined): InstrumentRef | null {
37
53
  return useMemo(() => instrumentFromTicker(ticker, symbol ?? null), [symbol, ticker]);
38
54
  }
@@ -54,7 +70,12 @@ export function useTickerFinancials(symbol: string | null | undefined, ticker: T
54
70
  }
55
71
 
56
72
  export function useTickerFinancialsMap(tickers: TickerRecord[]): Map<string, TickerFinancials> {
57
- return useCoordinatorSelector((coordinator) => {
73
+ const coordinator = getSharedMarketDataCoordinator();
74
+ const version = useCoordinatorVersion();
75
+ const tickerKey = buildTickerFinancialsMapKey(tickers);
76
+
77
+ return useMemo(() => {
78
+ if (!coordinator) return new Map<string, TickerFinancials>();
58
79
  const result = new Map<string, TickerFinancials>();
59
80
  for (const ticker of tickers) {
60
81
  const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
@@ -65,7 +86,7 @@ export function useTickerFinancialsMap(tickers: TickerRecord[]): Map<string, Tic
65
86
  }
66
87
  }
67
88
  return result;
68
- }, new Map<string, TickerFinancials>());
89
+ }, [coordinator, tickerKey, version]);
69
90
  }
70
91
 
71
92
  export function useQuoteEntry(symbol: string | null | undefined, ticker: TickerRecord | null | undefined): QueryEntry<Quote> | null {
@@ -85,15 +106,7 @@ export function useQuoteEntry(symbol: string | null | undefined, ticker: TickerR
85
106
  }
86
107
 
87
108
  export function useChartQuery(request: ChartRequest | null | undefined): QueryEntry<PricePoint[]> | null {
88
- const key = request ? [
89
- request.instrument.symbol,
90
- request.instrument.exchange ?? "",
91
- request.range,
92
- request.granularity ?? "daily",
93
- request.startDate?.toISOString() ?? "",
94
- request.endDate?.toISOString() ?? "",
95
- request.barSize ?? "",
96
- ].join("|") : null;
109
+ const key = request ? buildChartKey(request) : null;
97
110
  const entry = useCoordinatorSelector(
98
111
  (coordinator) => (request ? coordinator.getChartEntry(request) : null),
99
112
  null,
@@ -108,6 +121,23 @@ export function useChartQuery(request: ChartRequest | null | undefined): QueryEn
108
121
  return entry;
109
122
  }
110
123
 
124
+ export function useChartQueries(requests: readonly ChartRequest[]): Map<string, QueryEntry<PricePoint[]>> {
125
+ const requestKey = requests.map((request) => buildChartKey(request)).join(",");
126
+ const entries = useCoordinatorSelector((coordinator) => (
127
+ requests.map((request) => [buildChartKey(request), coordinator.getChartEntry(request)] as const)
128
+ ), [] as Array<readonly [string, QueryEntry<PricePoint[]>]>);
129
+
130
+ useEffect(() => {
131
+ const coordinator = getSharedMarketDataCoordinator();
132
+ if (!coordinator) return;
133
+ for (const request of requests) {
134
+ void coordinator.loadChart(request);
135
+ }
136
+ }, [requestKey]);
137
+
138
+ return useMemo(() => new Map(entries), [entries]);
139
+ }
140
+
111
141
  export function useNewsQuery(request: NewsRequest | null | undefined): QueryEntry<NewsItem[]> | null {
112
142
  const key = request ? `${request.instrument.symbol}|${request.instrument.exchange ?? ""}|${request.count ?? 50}` : null;
113
143
  const entry = useCoordinatorSelector(
@@ -203,10 +233,17 @@ export function useFxRate(currency: string | null | undefined): QueryEntry<numbe
203
233
  }
204
234
 
205
235
  export function useFxRatesMap(currencies: Array<string | null | undefined>): Map<string, number> {
206
- const normalizedCurrencies = useMemo(() => stableCurrencyList(currencies), [currencies]);
207
- const entries = useCoordinatorSelector((coordinator) => (
208
- normalizedCurrencies.map((currency) => [currency, coordinator.getFxEntry(currency)] as const)
209
- ), [] as Array<readonly [string, QueryEntry<number>]>);
236
+ const coordinator = getSharedMarketDataCoordinator();
237
+ const version = useCoordinatorVersion();
238
+ const normalizedCurrencyKey = stableCurrencyList(currencies).join("|");
239
+ const normalizedCurrencies = useMemo(
240
+ () => (normalizedCurrencyKey ? normalizedCurrencyKey.split("|") : []),
241
+ [normalizedCurrencyKey],
242
+ );
243
+ const entries = useMemo(() => {
244
+ if (!coordinator) return [] as Array<readonly [string, QueryEntry<number>]>;
245
+ return normalizedCurrencies.map((currency) => [currency, coordinator.getFxEntry(currency)] as const);
246
+ }, [coordinator, normalizedCurrencies, version]);
210
247
 
211
248
  useEffect(() => {
212
249
  const coordinator = getSharedMarketDataCoordinator();
@@ -214,7 +251,7 @@ export function useFxRatesMap(currencies: Array<string | null | undefined>): Map
214
251
  for (const currency of normalizedCurrencies) {
215
252
  void coordinator.loadFxRate(currency);
216
253
  }
217
- }, [normalizedCurrencies.join("|")]);
254
+ }, [normalizedCurrencyKey]);
218
255
 
219
256
  return useMemo(() => {
220
257
  const rates = new Map<string, number>();
@@ -1,4 +1,5 @@
1
1
  import type { TimeRange } from "../components/chart/chart-types";
2
+ import type { ManualChartResolution } from "../components/chart/chart-resolution";
2
3
  import type { QuoteSubscriptionTarget } from "../types/data-provider";
3
4
  import type { BrokerContractRef } from "../types/instrument";
4
5
  import type { TickerRecord } from "../types/ticker";
@@ -11,12 +12,13 @@ export interface InstrumentRef {
11
12
  instrument?: BrokerContractRef | null;
12
13
  }
13
14
 
14
- export type ChartGranularity = "daily" | "detail";
15
+ export type ChartGranularity = "range" | "detail" | "resolution";
15
16
 
16
17
  export interface ChartRequest {
17
18
  instrument: InstrumentRef;
18
- range: TimeRange;
19
+ bufferRange: TimeRange;
19
20
  granularity?: ChartGranularity;
21
+ resolution?: ManualChartResolution;
20
22
  startDate?: Date;
21
23
  endDate?: Date;
22
24
  barSize?: string;