gloomberb 0.4.0 → 0.5.0

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 (218) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +184 -69
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +296 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -1
  48. package/src/components/layout/header.tsx +5 -4
  49. package/src/components/layout/pane-sizing.ts +6 -0
  50. package/src/components/layout/pane.tsx +5 -1
  51. package/src/components/layout/shell.test.tsx +263 -32
  52. package/src/components/layout/shell.tsx +10 -6
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +62 -2
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +82 -5
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +92 -18
  103. package/src/plugins/builtin/chat-controller.ts +40 -11
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +420 -119
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +33 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/gateway-service.test.ts +22 -0
  166. package/src/plugins/ibkr/gateway-service.ts +6 -0
  167. package/src/plugins/ibkr/index.test.tsx +1 -1
  168. package/src/plugins/ibkr/index.tsx +8 -2
  169. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  170. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  171. package/src/plugins/ibkr/trade-utils.ts +7 -6
  172. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  173. package/src/plugins/plugin-runtime.test.tsx +2 -0
  174. package/src/plugins/plugin-runtime.tsx +10 -0
  175. package/src/plugins/prediction-markets/controller-data.ts +129 -14
  176. package/src/plugins/prediction-markets/controller.ts +1 -1
  177. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  178. package/src/plugins/prediction-markets/index.tsx +5 -1
  179. package/src/plugins/prediction-markets/pane.test.tsx +78 -0
  180. package/src/plugins/prediction-markets/pane.tsx +7 -3
  181. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  182. package/src/plugins/prediction-markets/services/fetch.ts +17 -7
  183. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  184. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  185. package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
  186. package/src/plugins/registry.ts +45 -13
  187. package/src/sources/provider-router.test.ts +3 -2
  188. package/src/sources/sec-edgar.ts +13 -1
  189. package/src/sources/yahoo-finance.ts +75 -1
  190. package/src/state/app-context-selector.test.tsx +17 -1
  191. package/src/state/app-context.test.tsx +24 -0
  192. package/src/state/app-context.tsx +29 -6
  193. package/src/theme/colors.ts +5 -0
  194. package/src/theme/themes.ts +25 -0
  195. package/src/types/config.test.ts +107 -0
  196. package/src/types/config.ts +30 -1
  197. package/src/types/data-provider.ts +13 -0
  198. package/src/types/plugin.ts +35 -1
  199. package/src/updater.test.ts +2 -0
  200. package/src/utils/api-client.ts +12 -0
  201. package/src/utils/format.test.ts +11 -1
  202. package/src/utils/format.ts +86 -8
  203. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  204. package/src/utils/inline-content-tokenizer.ts +76 -0
  205. package/src/utils/link-tokenizer.test.ts +27 -0
  206. package/src/utils/link-tokenizer.ts +93 -0
  207. package/src/utils/market-format.test.ts +109 -0
  208. package/src/utils/market-format.ts +299 -0
  209. package/src/utils/market-status.ts +3 -2
  210. package/src/utils/selection-clipboard.test.ts +43 -0
  211. package/src/utils/selection-clipboard.ts +88 -0
  212. package/src/utils/terminal-input-reset.test.ts +50 -0
  213. package/src/utils/terminal-input-reset.ts +24 -0
  214. package/src/utils/throttled-fetch.test.ts +145 -0
  215. package/src/utils/throttled-fetch.ts +212 -0
  216. package/src/utils/ticker-tokenizer.test.ts +31 -0
  217. package/src/utils/ticker-tokenizer.ts +7 -42
  218. package/src/version.ts +1 -1
package/src/index.tsx CHANGED
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync } from "fs";
7
7
  import { dispatchCli } from "./cli/index";
8
8
  import { loadExternalPlugins } from "./plugins/loader";
9
9
  import { debugLog } from "./utils/debug-log";
10
+ import { resetTerminalInputState } from "./utils/terminal-input-reset";
10
11
 
11
12
  async function main() {
12
13
  // Intercept console.log/warn/error to capture in debug log
@@ -43,6 +44,10 @@ async function main() {
43
44
  // Load or create config
44
45
  const config = await initDataDir(dataDir);
45
46
 
47
+ // `bun run --watch` does not guarantee the previous run's renderer can cleanly
48
+ // restore mouse/raw terminal modes before the next run starts.
49
+ resetTerminalInputState();
50
+
46
51
  // Create renderer
47
52
  const renderer = await createCliRenderer({
48
53
  exitOnCtrlC: true,
@@ -63,5 +68,5 @@ async function main() {
63
68
 
64
69
  main().catch((err) => {
65
70
  console.error("Fatal error:", err);
66
- process.exit(1);
71
+ process.exitCode = 1;
67
72
  });
@@ -92,6 +92,115 @@ describe("MarketDataCoordinator", () => {
92
92
  expect(second.lastGoodData?.length).toBe(2);
93
93
  });
94
94
 
95
+ it("uses cached chart data while a wider range request is loading", async () => {
96
+ const oneYearHistory = [
97
+ { date: new Date("2024-01-01"), close: 100 },
98
+ { date: new Date("2024-01-02"), close: 101 },
99
+ ];
100
+ const fiveYearHistory = [
101
+ { date: new Date("2020-01-01"), close: 80 },
102
+ ...oneYearHistory,
103
+ ];
104
+ let requestedFiveYear = false;
105
+ let resolveFiveYear: (history: PricePoint[] | PromiseLike<PricePoint[]>) => void = (_history) => {
106
+ throw new Error("expected pending 5Y request");
107
+ };
108
+ const provider = createProvider({
109
+ getPriceHistory: async (_symbol, _exchange, range) => {
110
+ if (range === "5Y") {
111
+ return new Promise<PricePoint[]>((resolve) => {
112
+ requestedFiveYear = true;
113
+ resolveFiveYear = resolve;
114
+ });
115
+ }
116
+ return oneYearHistory;
117
+ },
118
+ });
119
+ const coordinator = new MarketDataCoordinator(provider);
120
+ const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
121
+
122
+ await coordinator.loadChart({
123
+ instrument,
124
+ bufferRange: "1Y",
125
+ granularity: "range",
126
+ });
127
+ const pending = coordinator.loadChart({
128
+ instrument,
129
+ bufferRange: "5Y",
130
+ granularity: "range",
131
+ });
132
+
133
+ const loadingEntry = coordinator.getChartEntry({
134
+ instrument,
135
+ bufferRange: "5Y",
136
+ granularity: "range",
137
+ });
138
+ expect(loadingEntry.phase).toBe("refreshing");
139
+ expect(loadingEntry.data?.map((point) => point.close)).toEqual([100, 101]);
140
+
141
+ expect(requestedFiveYear).toBe(true);
142
+ resolveFiveYear(fiveYearHistory);
143
+ const readyEntry = await pending;
144
+ expect(readyEntry.phase).toBe("ready");
145
+ expect(readyEntry.data?.map((point) => point.close)).toEqual([80, 100, 101]);
146
+ });
147
+
148
+ it("uses cached manual-resolution data while a wider resolution range is loading", async () => {
149
+ const oneYearHistory = [
150
+ { date: new Date("2024-01-01"), close: 100 },
151
+ { date: new Date("2024-01-02"), close: 101 },
152
+ ];
153
+ const fiveYearHistory = [
154
+ { date: new Date("2020-01-01"), close: 80 },
155
+ ...oneYearHistory,
156
+ ];
157
+ let requestedFiveYear = false;
158
+ let resolveFiveYear: (history: PricePoint[] | PromiseLike<PricePoint[]>) => void = (_history) => {
159
+ throw new Error("expected pending 5Y request");
160
+ };
161
+ const provider = createProvider({
162
+ getPriceHistoryForResolution: async (_symbol, _exchange, range) => {
163
+ if (range === "5Y") {
164
+ return new Promise<PricePoint[]>((resolve) => {
165
+ requestedFiveYear = true;
166
+ resolveFiveYear = resolve;
167
+ });
168
+ }
169
+ return oneYearHistory;
170
+ },
171
+ });
172
+ const coordinator = new MarketDataCoordinator(provider);
173
+ const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
174
+
175
+ await coordinator.loadChart({
176
+ instrument,
177
+ bufferRange: "1Y",
178
+ granularity: "resolution",
179
+ resolution: "1d",
180
+ });
181
+ const pending = coordinator.loadChart({
182
+ instrument,
183
+ bufferRange: "5Y",
184
+ granularity: "resolution",
185
+ resolution: "1d",
186
+ });
187
+
188
+ const loadingEntry = coordinator.getChartEntry({
189
+ instrument,
190
+ bufferRange: "5Y",
191
+ granularity: "resolution",
192
+ resolution: "1d",
193
+ });
194
+ expect(loadingEntry.phase).toBe("refreshing");
195
+ expect(loadingEntry.data?.map((point) => point.close)).toEqual([100, 101]);
196
+
197
+ expect(requestedFiveYear).toBe(true);
198
+ resolveFiveYear(fiveYearHistory);
199
+ const readyEntry = await pending;
200
+ expect(readyEntry.phase).toBe("ready");
201
+ expect(readyEntry.data?.map((point) => point.close)).toEqual([80, 100, 101]);
202
+ });
203
+
95
204
  it("normalizes descending chart history before storing it", async () => {
96
205
  const provider = createProvider({
97
206
  getPriceHistory: async () => [
@@ -124,7 +233,7 @@ describe("MarketDataCoordinator", () => {
124
233
  const instrument = { symbol: "MSFT", exchange: "NASDAQ" };
125
234
 
126
235
  coordinator.subscribeQuotes([{ instrument }]);
127
- const onStreamed = streamed;
236
+ const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
128
237
  if (!onStreamed) throw new Error("expected streaming callback");
129
238
  onStreamed(
130
239
  { symbol: "MSFT", exchange: "NASDAQ" },
@@ -162,7 +271,7 @@ describe("MarketDataCoordinator", () => {
162
271
  resolution: "1d",
163
272
  });
164
273
 
165
- expect(requested).toEqual({ range: "1Y", resolution: "1d" });
274
+ expect(requested as { range: string; resolution: string } | null).toEqual({ range: "1Y", resolution: "1d" });
166
275
  expect(entry.data?.length).toBe(2);
167
276
  });
168
277
 
@@ -196,7 +305,7 @@ describe("MarketDataCoordinator", () => {
196
305
 
197
306
  await coordinator.loadSnapshot(instrument);
198
307
  coordinator.subscribeQuotes([{ instrument }]);
199
- const onStreamed = streamed;
308
+ const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
200
309
  if (!onStreamed) throw new Error("expected streaming callback");
201
310
  onStreamed(
202
311
  {
@@ -292,7 +401,7 @@ describe("MarketDataCoordinator", () => {
292
401
 
293
402
  await coordinator.loadSnapshot(instrument);
294
403
  coordinator.subscribeQuotes([{ instrument }]);
295
- const onStreamed = streamed;
404
+ const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
296
405
  if (!onStreamed) throw new Error("expected streaming callback");
297
406
  onStreamed(
298
407
  { symbol: "IQE", exchange: "LSE" },
@@ -347,7 +456,7 @@ describe("MarketDataCoordinator", () => {
347
456
 
348
457
  await coordinator.loadSnapshot(instrument);
349
458
  coordinator.subscribeQuotes([{ instrument }]);
350
- const onStreamed = streamed;
459
+ const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
351
460
  if (!onStreamed) throw new Error("expected streaming callback");
352
461
  onStreamed(
353
462
  { symbol: "HY9H", exchange: "FWB2" },
@@ -8,6 +8,7 @@ import {
8
8
  buildArticleSummaryKey,
9
9
  buildChartKey,
10
10
  buildFxKey,
11
+ buildInstrumentKey,
11
12
  buildTickerFinancialsSnapshot,
12
13
  buildNewsKey,
13
14
  buildOptionsKey,
@@ -24,9 +25,11 @@ import {
24
25
  import { traceMarketData } from "./trace";
25
26
  import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
26
27
  import { hasFreshQuoteForCurrentSession, isQuoteStaleForCurrentSession } from "../utils/quote-freshness";
28
+ import { TIME_RANGE_ORDER } from "../components/chart/chart-resolution";
27
29
 
28
30
  const EMPTY_MESSAGE = "No data available";
29
31
  const EXPECTED_EMPTY = /no data|not found|delisted|unavailable|unsupported/i;
32
+ const TIME_RANGE_INDEX = new Map(TIME_RANGE_ORDER.map((range, index) => [range, index]));
30
33
 
31
34
  function createBaselineChartRequest(instrument: InstrumentRef): ChartRequest {
32
35
  return {
@@ -36,6 +39,27 @@ function createBaselineChartRequest(instrument: InstrumentRef): ChartRequest {
36
39
  };
37
40
  }
38
41
 
42
+ function getChartGranularity(request: ChartRequest): NonNullable<ChartRequest["granularity"]> {
43
+ return request.granularity ?? "range";
44
+ }
45
+
46
+ function getTimeRangeIndex(range: ChartRequest["bufferRange"]): number {
47
+ return TIME_RANGE_INDEX.get(range) ?? 0;
48
+ }
49
+
50
+ function isSeedableChartRequest(
51
+ target: ChartRequest,
52
+ candidate: ChartRequest,
53
+ ): boolean {
54
+ const targetGranularity = getChartGranularity(target);
55
+ const candidateGranularity = getChartGranularity(candidate);
56
+ if (targetGranularity !== candidateGranularity) return false;
57
+ if (targetGranularity === "detail") return false;
58
+ if (targetGranularity === "resolution" && target.resolution !== candidate.resolution) return false;
59
+ if (buildInstrumentKey(target.instrument) !== buildInstrumentKey(candidate.instrument)) return false;
60
+ return getTimeRangeIndex(candidate.bufferRange) <= getTimeRangeIndex(target.bufferRange);
61
+ }
62
+
39
63
  function classifyError(error: unknown): { reasonCode: ProviderReasonCode; message: string } {
40
64
  const message = error instanceof Error ? error.message : String(error ?? "");
41
65
  if (/timeout/i.test(message)) return { reasonCode: "TIMEOUT", message };
@@ -125,6 +149,7 @@ export class MarketDataCoordinator {
125
149
  private version = 0;
126
150
  private readonly listeners = new Set<() => void>();
127
151
  private readonly inFlight = new Map<string, Promise<unknown>>();
152
+ private readonly chartRequests = new Map<string, ChartRequest>();
128
153
 
129
154
  private readonly quoteStore = new QueryStore<Quote>(() => this.bump());
130
155
  private readonly snapshotStore = new QueryStore<TickerFinancials>(() => this.bump());
@@ -281,6 +306,49 @@ export class MarketDataCoordinator {
281
306
  return promise;
282
307
  }
283
308
 
309
+ private findChartSeedEntry(
310
+ key: string,
311
+ request: ChartRequest,
312
+ ): { entry: QueryEntry<PricePoint[]>; data: PricePoint[]; score: number } | null {
313
+ let best: { entry: QueryEntry<PricePoint[]>; data: PricePoint[]; score: number } | null = null;
314
+ for (const [candidateKey, candidateRequest] of this.chartRequests) {
315
+ if (candidateKey === key) continue;
316
+ if (!isSeedableChartRequest(request, candidateRequest)) continue;
317
+ const entry = this.chartStore.get(candidateKey);
318
+ const data = resolveEntryData(entry);
319
+ if (!data?.length) continue;
320
+ const score = getTimeRangeIndex(candidateRequest.bufferRange);
321
+ if (!best || score > best.score) {
322
+ best = { entry, data, score };
323
+ }
324
+ }
325
+ return best;
326
+ }
327
+
328
+ private createChartLoadingEntry(
329
+ key: string,
330
+ request: ChartRequest,
331
+ current: QueryEntry<PricePoint[]>,
332
+ ): QueryEntry<PricePoint[]> {
333
+ if (resolveEntryData(current)?.length) {
334
+ return loadingEntry(current);
335
+ }
336
+
337
+ const seed = this.findChartSeedEntry(key, request);
338
+ if (!seed) {
339
+ return loadingEntry(current);
340
+ }
341
+
342
+ return loadingEntry({
343
+ ...current,
344
+ data: seed.data,
345
+ lastGoodData: seed.entry.lastGoodData?.length ? seed.entry.lastGoodData : seed.data,
346
+ source: seed.entry.source,
347
+ fetchedAt: seed.entry.fetchedAt,
348
+ staleAt: seed.entry.staleAt,
349
+ });
350
+ }
351
+
284
352
  async loadSnapshot(
285
353
  instrument: InstrumentRef,
286
354
  options: { forceRefresh?: boolean } = {},
@@ -379,9 +447,10 @@ export class MarketDataCoordinator {
379
447
  options: { forceRefresh?: boolean } = {},
380
448
  ): Promise<QueryEntry<PricePoint[]>> {
381
449
  const key = buildChartKey(request);
450
+ this.chartRequests.set(key, request);
382
451
  const flightKey = options.forceRefresh ? `${key}|refresh` : key;
383
452
  return this.runSingleFlight(flightKey, async () => {
384
- this.chartStore.update(key, loadingEntry);
453
+ this.chartStore.update(key, (current) => this.createChartLoadingEntry(key, request, current));
385
454
  const startedAt = Date.now();
386
455
  try {
387
456
  const data = normalizePriceHistory(
@@ -1,13 +1,16 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { act, useState } from "react";
3
3
  import { testRender } from "@opentui/react/test-utils";
4
- import type { TickerFinancials } from "../types/financials";
4
+ import type { PricePoint, TickerFinancials } from "../types/financials";
5
5
  import type { TickerRecord } from "../types/ticker";
6
6
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "./coordinator";
7
- import { useFxRatesMap, useTickerFinancialsMap } from "./hooks";
7
+ import { useChartQueries, useFxRatesMap, useTickerFinancialsMap } from "./hooks";
8
+ import type { ChartRequest } from "./request-types";
9
+ import { createIdleEntry } from "./result-types";
8
10
 
9
11
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
10
12
  let bumpHarness: (() => void) | null = null;
13
+ let replaceChartRequests: ((requests: readonly ChartRequest[]) => void) | null = null;
11
14
  let latestFxRates: Map<string, number> | null = null;
12
15
  let latestFinancialsMap: Map<string, TickerFinancials> | null = null;
13
16
 
@@ -70,6 +73,31 @@ function HooksHarness() {
70
73
  return <text>{String(tick)}</text>;
71
74
  }
72
75
 
76
+ function ChartQueriesHarness({
77
+ initialRequests,
78
+ debounceMs,
79
+ }: {
80
+ initialRequests: readonly ChartRequest[];
81
+ debounceMs: number;
82
+ }) {
83
+ const [requests, setRequests] = useState<readonly ChartRequest[]>(initialRequests);
84
+ replaceChartRequests = setRequests;
85
+ useChartQueries(requests, { debounceMs });
86
+
87
+ return <text>{String(requests.length)}</text>;
88
+ }
89
+
90
+ function makeChartRequest(bufferRange: ChartRequest["bufferRange"]): ChartRequest {
91
+ return {
92
+ instrument: {
93
+ symbol: "AAPL",
94
+ exchange: "NASDAQ",
95
+ },
96
+ bufferRange,
97
+ granularity: "range",
98
+ };
99
+ }
100
+
73
101
  afterEach(async () => {
74
102
  if (testSetup) {
75
103
  await act(async () => {
@@ -78,6 +106,7 @@ afterEach(async () => {
78
106
  testSetup = undefined;
79
107
  }
80
108
  bumpHarness = null;
109
+ replaceChartRequests = null;
81
110
  latestFxRates = null;
82
111
  latestFinancialsMap = null;
83
112
  setSharedMarketDataCoordinator(null);
@@ -117,4 +146,46 @@ describe("market-data hooks", () => {
117
146
  expect(latestFxRates).toBe(initialFxRates);
118
147
  expect(latestFinancialsMap).toBe(initialFinancialsMap);
119
148
  });
149
+
150
+ test("debounces chart query batches and cancels superseded schedules", async () => {
151
+ const loadedRanges: string[] = [];
152
+ const idleChartEntry = createIdleEntry<PricePoint[]>();
153
+ const coordinator = {
154
+ subscribe: () => () => {},
155
+ getVersion: () => 1,
156
+ getChartEntry: () => idleChartEntry,
157
+ loadChart: async (request: ChartRequest) => {
158
+ loadedRanges.push(request.bufferRange);
159
+ return idleChartEntry;
160
+ },
161
+ };
162
+ setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
163
+
164
+ testSetup = await testRender(
165
+ <ChartQueriesHarness initialRequests={[makeChartRequest("1D")]} debounceMs={40} />,
166
+ {
167
+ width: 20,
168
+ height: 1,
169
+ },
170
+ );
171
+
172
+ await act(async () => {
173
+ await testSetup!.renderOnce();
174
+ });
175
+ await act(async () => {
176
+ replaceChartRequests?.([makeChartRequest("1W")]);
177
+ await testSetup!.renderOnce();
178
+ });
179
+ await act(async () => {
180
+ await new Promise((resolve) => setTimeout(resolve, 15));
181
+ replaceChartRequests?.([makeChartRequest("1M")]);
182
+ await testSetup!.renderOnce();
183
+ });
184
+ await act(async () => {
185
+ await new Promise((resolve) => setTimeout(resolve, 60));
186
+ await testSetup!.renderOnce();
187
+ });
188
+
189
+ expect(loadedRanges).toEqual(["1M"]);
190
+ });
120
191
  });
@@ -121,8 +121,12 @@ export function useChartQuery(request: ChartRequest | null | undefined): QueryEn
121
121
  return entry;
122
122
  }
123
123
 
124
- export function useChartQueries(requests: readonly ChartRequest[]): Map<string, QueryEntry<PricePoint[]>> {
124
+ export function useChartQueries(
125
+ requests: readonly ChartRequest[],
126
+ options: { debounceMs?: number } = {},
127
+ ): Map<string, QueryEntry<PricePoint[]>> {
125
128
  const requestKey = requests.map((request) => buildChartKey(request)).join(",");
129
+ const debounceMs = Math.max(0, options.debounceMs ?? 0);
126
130
  const entries = useCoordinatorSelector((coordinator) => (
127
131
  requests.map((request) => [buildChartKey(request), coordinator.getChartEntry(request)] as const)
128
132
  ), [] as Array<readonly [string, QueryEntry<PricePoint[]>]>);
@@ -130,10 +134,18 @@ export function useChartQueries(requests: readonly ChartRequest[]): Map<string,
130
134
  useEffect(() => {
131
135
  const coordinator = getSharedMarketDataCoordinator();
132
136
  if (!coordinator) return;
133
- for (const request of requests) {
134
- void coordinator.loadChart(request);
137
+ const loadRequests = () => {
138
+ for (const request of requests) {
139
+ void coordinator.loadChart(request);
140
+ }
141
+ };
142
+ if (debounceMs <= 0) {
143
+ loadRequests();
144
+ return;
135
145
  }
136
- }, [requestKey]);
146
+ const timeout = setTimeout(loadRequests, debounceMs);
147
+ return () => clearTimeout(timeout);
148
+ }, [debounceMs, requestKey]);
137
149
 
138
150
  return useMemo(() => new Map(entries), [entries]);
139
151
  }
@@ -0,0 +1,177 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildDesktopNotificationCommand,
4
+ buildSoundCommand,
5
+ createAppNotifier,
6
+ createDesktopNotifier,
7
+ } from "./app-notifier";
8
+
9
+ describe("desktop notification commands", () => {
10
+ test("builds a macOS notification command", () => {
11
+ const command = buildDesktopNotificationCommand({
12
+ title: "Chat mention",
13
+ body: "@bob mentioned you",
14
+ subtitle: "#everyone",
15
+ }, "darwin");
16
+
17
+ expect(command).toEqual({
18
+ command: "osascript",
19
+ args: ["-e", "display notification \"@bob mentioned you\" with title \"Chat mention\" subtitle \"#everyone\""],
20
+ });
21
+ });
22
+
23
+ test("builds a Linux notification command", () => {
24
+ const command = buildDesktopNotificationCommand({
25
+ title: "Chat mention",
26
+ body: "@bob mentioned you",
27
+ subtitle: "#everyone",
28
+ type: "error",
29
+ }, "linux");
30
+
31
+ expect(command).toEqual({
32
+ command: "notify-send",
33
+ args: ["--app-name", "Gloomberb", "--urgency", "critical", "Chat mention", "#everyone\n@bob mentioned you"],
34
+ });
35
+ });
36
+
37
+ test("builds a Windows notification command", () => {
38
+ const command = buildDesktopNotificationCommand({
39
+ title: "Chat mention",
40
+ body: "@bob mentioned you",
41
+ subtitle: "#everyone",
42
+ }, "win32");
43
+
44
+ expect(command?.command).toBe("powershell.exe");
45
+ expect(command?.args[0]).toBe("-NoProfile");
46
+ expect(command?.args.at(-1)).toContain("$notify.BalloonTipTitle = 'Chat mention';");
47
+ expect(command?.args.at(-1)).toContain("$notify.BalloonTipText = '#everyone\n@bob mentioned you';");
48
+ });
49
+
50
+ test("adds macOS notification sound when requested", () => {
51
+ const command = buildDesktopNotificationCommand({
52
+ title: "Price alert",
53
+ body: "AAPL triggered",
54
+ sound: "Glass",
55
+ }, "darwin");
56
+
57
+ expect(command?.args[1]).toContain("sound name \"Glass\"");
58
+ });
59
+ });
60
+
61
+ describe("notification sound commands", () => {
62
+ test("builds platform sound commands", () => {
63
+ expect(buildSoundCommand("Glass", "darwin")).toEqual({
64
+ command: "afplay",
65
+ args: ["/System/Library/Sounds/Glass.aiff"],
66
+ });
67
+ expect(buildSoundCommand("Ping", "linux")).toEqual({
68
+ command: "paplay",
69
+ args: ["/usr/share/sounds/freedesktop/stereo/message-new-instant.oga"],
70
+ });
71
+ expect(buildSoundCommand("Glass", "win32")?.command).toBe("powershell.exe");
72
+ });
73
+ });
74
+
75
+ describe("app notifier", () => {
76
+ test("shows a toast and sends a desktop notification when inactive", () => {
77
+ const toasts: Array<{ message: string; type?: string; duration?: number }> = [];
78
+ const desktops: string[] = [];
79
+ let active = false;
80
+ const notifier = createAppNotifier({
81
+ isAppActive: () => active,
82
+ renderToast: (notification) => {
83
+ toasts.push({
84
+ message: notification.body,
85
+ type: notification.type,
86
+ duration: notification.duration,
87
+ });
88
+ },
89
+ desktop: {
90
+ notify(notification) {
91
+ desktops.push(notification.body);
92
+ },
93
+ },
94
+ });
95
+
96
+ notifier.notify({
97
+ title: "Chat mention",
98
+ body: "@bob mentioned you",
99
+ type: "info",
100
+ desktop: "when-inactive",
101
+ duration: 5000,
102
+ });
103
+
104
+ expect(toasts).toEqual([{ message: "@bob mentioned you", type: "info", duration: 5000 }]);
105
+ expect(desktops).toEqual(["@bob mentioned you"]);
106
+
107
+ active = true;
108
+ notifier.notify({
109
+ body: "Visible toast only",
110
+ type: "success",
111
+ desktop: "when-inactive",
112
+ });
113
+
114
+ expect(toasts).toEqual([
115
+ { message: "@bob mentioned you", type: "info", duration: 5000 },
116
+ { message: "Visible toast only", type: "success", duration: undefined },
117
+ ]);
118
+ expect(desktops).toEqual(["@bob mentioned you"]);
119
+ });
120
+
121
+ test("toast-disabled notifications skip toast delivery", () => {
122
+ const toasts: string[] = [];
123
+ const desktops: string[] = [];
124
+ const notifier = createAppNotifier({
125
+ isAppActive: () => false,
126
+ renderToast: (notification) => {
127
+ toasts.push(notification.body);
128
+ },
129
+ desktop: {
130
+ notify(notification) {
131
+ desktops.push(notification.body);
132
+ },
133
+ },
134
+ });
135
+
136
+ notifier.notify({ body: "Saved", type: "success", toast: false, desktop: "when-inactive" });
137
+
138
+ expect(toasts).toEqual([]);
139
+ expect(desktops).toEqual(["Saved"]);
140
+ });
141
+ });
142
+
143
+ describe("desktop notifier", () => {
144
+ test("stops retrying a missing desktop notification command", () => {
145
+ const calls: string[] = [];
146
+ const notifier = createDesktopNotifier({
147
+ platform: "linux",
148
+ runner: {
149
+ run(command, _args, handlers) {
150
+ calls.push(command);
151
+ handlers?.onError?.(Object.assign(new Error("missing"), { code: "ENOENT" }));
152
+ },
153
+ },
154
+ });
155
+
156
+ notifier.notify({ body: "first" });
157
+ notifier.notify({ body: "second" });
158
+
159
+ expect(calls).toEqual(["notify-send"]);
160
+ });
161
+
162
+ test("plays requested sounds alongside desktop notifications", () => {
163
+ const calls: string[] = [];
164
+ const notifier = createDesktopNotifier({
165
+ platform: "linux",
166
+ runner: {
167
+ run(command) {
168
+ calls.push(command);
169
+ },
170
+ },
171
+ });
172
+
173
+ notifier.notify({ body: "AAPL triggered", sound: "Glass" });
174
+
175
+ expect(calls).toEqual(["notify-send", "paplay"]);
176
+ });
177
+ });