gloomberb 0.4.1 → 0.5.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 (212) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  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 +292 -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 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  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 +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  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 +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  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 +24 -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/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
@@ -363,6 +363,53 @@ describe("prediction markets plugin registration and services", () => {
363
363
  expect(markets[0]?.marketLabel).toBe("April 30");
364
364
  });
365
365
 
366
+ test("keeps Polymarket catalog results when one page connection resets", async () => {
367
+ let resetFetchCount = 0;
368
+
369
+ globalThis.fetch = (async (input: Request | string | URL) => {
370
+ const url = String(input);
371
+ if (url.includes("gamma-api.polymarket.com/events?")) {
372
+ if (url.includes("offset=200")) {
373
+ resetFetchCount += 1;
374
+ throw Object.assign(
375
+ new Error("The socket connection was closed unexpectedly."),
376
+ { code: "ECONNRESET" },
377
+ );
378
+ }
379
+ return new Response(
380
+ JSON.stringify([
381
+ {
382
+ id: "event-stable",
383
+ title: "Stable catalog page",
384
+ tags: [{ label: "Macro", slug: "economy" }],
385
+ markets: [
386
+ {
387
+ id: "pm-stable",
388
+ question: "Will the stable page load?",
389
+ conditionId: "cond-stable",
390
+ outcomes: '["Yes","No"]',
391
+ outcomePrices: '["0.57","0.43"]',
392
+ clobTokenIds: '["yes-stable","no-stable"]',
393
+ volume24hr: 125000,
394
+ active: true,
395
+ closed: false,
396
+ },
397
+ ],
398
+ },
399
+ ]),
400
+ { status: 200 },
401
+ );
402
+ }
403
+ return new Response(JSON.stringify({}), { status: 200 });
404
+ }) as unknown as typeof fetch;
405
+
406
+ const markets = await loadPolymarketCatalog("", "all");
407
+
408
+ expect(markets).toHaveLength(1);
409
+ expect(markets[0]?.marketId).toBe("pm-stable");
410
+ expect(resetFetchCount).toBe(3);
411
+ });
412
+
366
413
  test("uses remote catalog endpoints for search and category changes", async () => {
367
414
  const { fetchUrls } = installPredictionMarketMocks();
368
415
 
@@ -420,7 +467,7 @@ describe("prediction markets plugin registration and services", () => {
420
467
  }
421
468
  if (url.includes("gamma-api.polymarket.com/events/event-1")) {
422
469
  eventFetchCount += 1;
423
- if (eventFetchCount === 1) {
470
+ if (eventFetchCount <= 3) {
424
471
  return new Response("{}", { status: 500 });
425
472
  }
426
473
  return new Response(
@@ -1,6 +1,18 @@
1
1
  import type { PluginPersistence } from "../../../types/plugin";
2
+ import { createThrottledFetch } from "../../../utils/throttled-fetch";
2
3
 
3
4
  const DEFAULT_SOURCE_KEY = "remote";
5
+ const PREDICTION_FETCH = createThrottledFetch({
6
+ requestsPerMinute: 120,
7
+ maxRetries: 2,
8
+ timeoutMs: 10_000,
9
+ backoffBaseMs: 250,
10
+ dedupeGetRequests: false,
11
+ defaultHeaders: {
12
+ Accept: "application/json",
13
+ "User-Agent": "gloomberb-prediction-markets",
14
+ },
15
+ });
4
16
 
5
17
  export const PREDICTION_CACHE_POLICIES = {
6
18
  catalog: { staleMs: 30_000, expireMs: 10 * 60_000 },
@@ -28,13 +40,7 @@ export function getPredictionMarketsPersistence(): PluginPersistence | null {
28
40
  }
29
41
 
30
42
  export async function fetchJson<T>(url: string): Promise<T> {
31
- const response = await fetch(url, {
32
- signal: AbortSignal.timeout(10_000),
33
- headers: {
34
- Accept: "application/json",
35
- "User-Agent": "gloomberb-prediction-markets",
36
- },
37
- });
43
+ const response = await PREDICTION_FETCH.fetch(url);
38
44
  if (!response.ok) {
39
45
  throw new Error(`Request failed (${response.status}) for ${url}`);
40
46
  }
@@ -219,6 +219,27 @@ function buildPolymarketSearchUrl(query: string): string {
219
219
  return url.toString();
220
220
  }
221
221
 
222
+ async function loadPolymarketCatalogPages(
223
+ offsets: number[],
224
+ tagSlug?: string,
225
+ ): Promise<PolymarketEventRecord[]> {
226
+ const results = await Promise.allSettled(
227
+ offsets.map((offset) =>
228
+ fetchJson<PolymarketEventRecord[]>(
229
+ buildPolymarketCatalogUrl(offset, tagSlug),
230
+ ),
231
+ ),
232
+ );
233
+ const pages = results.flatMap((result) =>
234
+ result.status === "fulfilled" ? result.value : [],
235
+ );
236
+ if (pages.length > 0) return pages;
237
+
238
+ const rejected = results.find((result) => result.status === "rejected");
239
+ if (rejected?.status === "rejected") throw rejected.reason;
240
+ return [];
241
+ }
242
+
222
243
  export function normalizePolymarketMarket(
223
244
  record: PolymarketMarketRecord,
224
245
  options?: {
@@ -360,12 +381,11 @@ export async function loadPolymarketCatalog(
360
381
  if (categoryId !== "all") {
361
382
  const tagSlugs = getPolymarketCategoryTagSlugs(categoryId);
362
383
  const categoryPages = await Promise.all(
363
- tagSlugs.flatMap((tagSlug) =>
364
- POLYMARKET_CATEGORY_OFFSETS.map((offset) =>
365
- fetchJson<PolymarketEventRecord[]>(
366
- buildPolymarketCatalogUrl(offset, tagSlug),
367
- ).catch(() => []),
368
- ),
384
+ tagSlugs.map((tagSlug) =>
385
+ loadPolymarketCatalogPages(
386
+ POLYMARKET_CATEGORY_OFFSETS,
387
+ tagSlug,
388
+ ).catch(() => []),
369
389
  ),
370
390
  );
371
391
  const categorized = sortPolymarketMarkets(
@@ -374,13 +394,11 @@ export async function loadPolymarketCatalog(
374
394
  if (categorized.length > 0) return categorized;
375
395
  }
376
396
 
377
- const pages = await Promise.all(
378
- POLYMARKET_CATALOG_OFFSETS.map((offset) =>
379
- fetchJson<PolymarketEventRecord[]>(buildPolymarketCatalogUrl(offset)),
380
- ),
397
+ const pages = await loadPolymarketCatalogPages(
398
+ POLYMARKET_CATALOG_OFFSETS,
381
399
  );
382
400
  return sortPolymarketMarkets(
383
- flattenPolymarketEvents(pages.flat(), "", categoryId),
401
+ flattenPolymarketEvents(pages, "", categoryId),
384
402
  );
385
403
  },
386
404
  PREDICTION_CACHE_POLICIES.catalog,
@@ -1,6 +1,8 @@
1
1
  import type { PredictionBookLevel, PredictionTrade } from "../types";
2
2
  import { parseFloatSafe } from "./fetch";
3
3
 
4
+ const POLYMARKET_HEARTBEAT_MS = 10_000;
5
+
4
6
  interface PolymarketLiveCallbacks {
5
7
  onBestBidAsk?: (
6
8
  assetId: string,
@@ -38,8 +40,23 @@ export function subscribePolymarketMarket(
38
40
  let closed = false;
39
41
  let socket: WebSocket | null = null;
40
42
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
43
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
44
+
45
+ const clearHeartbeat = () => {
46
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
47
+ heartbeatTimer = null;
48
+ };
49
+
50
+ const sendHeartbeat = () => {
51
+ try {
52
+ socket?.send("PING");
53
+ } catch {
54
+ // The close handler will schedule reconnects for broken sockets.
55
+ }
56
+ };
41
57
 
42
58
  const connect = () => {
59
+ clearHeartbeat();
43
60
  socket = new WebSocket(
44
61
  "wss://ws-subscriptions-clob.polymarket.com/ws/market",
45
62
  );
@@ -51,6 +68,7 @@ export function subscribePolymarketMarket(
51
68
  custom_feature_enabled: true,
52
69
  }),
53
70
  );
71
+ heartbeatTimer = setInterval(sendHeartbeat, POLYMARKET_HEARTBEAT_MS);
54
72
  });
55
73
  socket.addEventListener("message", (event) => {
56
74
  try {
@@ -89,6 +107,7 @@ export function subscribePolymarketMarket(
89
107
  }
90
108
  });
91
109
  socket.addEventListener("close", () => {
110
+ clearHeartbeat();
92
111
  socket = null;
93
112
  if (closed) return;
94
113
  reconnectTimer = setTimeout(connect, 1_500);
@@ -103,6 +122,7 @@ export function subscribePolymarketMarket(
103
122
  return () => {
104
123
  closed = true;
105
124
  if (reconnectTimer) clearTimeout(reconnectTimer);
125
+ clearHeartbeat();
106
126
  socket?.close();
107
127
  };
108
128
  }
@@ -137,6 +137,8 @@ export function createRuntime(): PluginRuntimeAccess {
137
137
  const listeners = new Map<string, Set<() => void>>();
138
138
 
139
139
  return {
140
+ pinTicker() {},
141
+ navigateTicker() {},
140
142
  subscribeResumeState(pluginId, key, listener) {
141
143
  const listenerKey = `${pluginId}:${key}`;
142
144
  if (!listeners.has(listenerKey)) listeners.set(listenerKey, new Set());
@@ -8,6 +8,7 @@ import type { BrokerInstanceConfig, LayoutConfig, PaneInstanceConfig } from "../
8
8
  import type { DataProvider } from "../types/data-provider";
9
9
  import type { TickerFinancials } from "../types/financials";
10
10
  import type {
11
+ AppNotificationRequest,
11
12
  CommandDef,
12
13
  CustomColumnDef,
13
14
  DetailTabDef,
@@ -27,7 +28,7 @@ import type {
27
28
  import type { TickerRecord } from "../types/ticker";
28
29
  import { addPaneFloating, removePane } from "./pane-manager";
29
30
  import { EventBus, type PluginEvents } from "./event-bus";
30
- import { createPaneInstance } from "../types/config";
31
+ import { createPaneInstance, resolvePaneInstance } from "../types/config";
31
32
  import { createPluginPersistence } from "./plugin-persistence";
32
33
  import { debugLog } from "../utils/debug-log";
33
34
  import { PluginRenderProvider, type PluginRuntimeAccess } from "./plugin-runtime";
@@ -104,6 +105,7 @@ export class PluginRegistry implements PluginRuntimeAccess {
104
105
  switchPanelFn: ((panel: "left" | "right") => void) = () => {};
105
106
  switchTabFn: ((tabId: string, paneId?: string) => void) = () => {};
106
107
  openCommandBarFn: ((query?: string) => void) = () => {};
108
+ openPluginCommandWorkflowFn: ((commandId: string) => void) = () => {};
107
109
  openPaneSettingsFn: ((paneId?: string) => void) = () => {};
108
110
  showPaneFn: ((paneId: string) => void) = () => {};
109
111
  createPaneFromTemplateFn: ((templateId: string, options?: PaneTemplateCreateOptions) => void) = () => {};
@@ -111,12 +113,22 @@ export class PluginRegistry implements PluginRuntimeAccess {
111
113
  hidePaneFn: ((paneId: string) => void) = () => {};
112
114
  focusPaneFn: ((paneId: string) => void) = () => {};
113
115
  pinTickerFn: ((symbol: string, options?: { floating?: boolean; paneType?: string }) => void) = () => {};
116
+ navigateTickerFn: ((symbol: string) => void) = () => {};
117
+ pinTicker = (symbol: string, options?: { floating?: boolean; paneType?: string }) => {
118
+ this.pinTickerFn(symbol, options);
119
+ };
120
+ navigateTicker = (symbol: string) => {
121
+ this.navigateTickerFn(symbol);
122
+ };
123
+ openPluginCommandWorkflow = (commandId: string) => {
124
+ this.openPluginCommandWorkflowFn(commandId);
125
+ };
114
126
 
115
127
  getLayoutFn: (() => LayoutConfig) = () => ({ dockRoot: null, instances: [], floating: [] });
116
128
  updateLayoutFn: ((layout: LayoutConfig) => void) = () => {};
117
129
  getTermSizeFn: (() => { width: number; height: number }) = () => ({ width: 120, height: 40 });
118
130
 
119
- showToastFn: ((message: string, options?: { duration?: number; type?: "info" | "success" | "error" }) => void) = () => {};
131
+ notifyFn: ((notification: AppNotificationRequest) => void) = () => {};
120
132
  getPaneRuntimeStateFn: ((paneId: string) => PaneRuntimeState | null) = () => null;
121
133
  updatePaneRuntimeStateFn: ((paneId: string, patch: Partial<PaneRuntimeState>) => void) = () => {};
122
134
  applyPaneSettingValueFn: ((paneId: string, field: import("../types/plugin").PaneSettingField, value: unknown) => Promise<void>) = async () => {};
@@ -177,6 +189,10 @@ export class PluginRegistry implements PluginRuntimeAccess {
177
189
  return this.pluginItems.get(pluginId)?.paneTemplates ?? [];
178
190
  }
179
191
 
192
+ notify(notification: AppNotificationRequest): void {
193
+ this.notifyFn(notification);
194
+ }
195
+
180
196
  private getOrCreatePluginItems(pluginId: string): PluginItems {
181
197
  const existing = this.pluginItems.get(pluginId);
182
198
  if (existing) return existing;
@@ -272,16 +288,8 @@ export class PluginRegistry implements PluginRuntimeAccess {
272
288
  return Object.keys(this.getConfigFn().pluginConfig[pluginId] ?? {}).sort();
273
289
  }
274
290
 
275
- private resolvePrimaryPaneInstanceId(paneId: string): string | undefined {
276
- const layout = this.getLayoutFn();
277
- return layout.instances.find((instance) => instance.paneId === paneId)?.instanceId;
278
- }
279
-
280
291
  private resolvePaneTarget(paneId: string): string | undefined {
281
- const layout = this.getLayoutFn();
282
- const isInstanceId = layout.instances.some((instance) => instance.instanceId === paneId);
283
- if (isInstanceId) return paneId;
284
- return this.resolvePrimaryPaneInstanceId(paneId);
292
+ return resolvePaneInstance(this.getLayoutFn(), paneId)?.instanceId;
285
293
  }
286
294
 
287
295
  resolvePaneSettings(paneId: string): {
@@ -514,7 +522,8 @@ export class PluginRegistry implements PluginRuntimeAccess {
514
522
  createPaneFromTemplate: (templateId, options) => this.createPaneFromTemplateFn(templateId, options),
515
523
  hidePane: (paneId) => this.hidePaneFn(paneId),
516
524
  focusPane: (paneId) => this.focusPaneFn(paneId),
517
- pinTicker: (symbol, options) => this.pinTickerFn(symbol, options),
525
+ pinTicker: this.pinTicker,
526
+ navigateTicker: this.navigateTicker,
518
527
  openPaneSettings: (paneId) => this.openPaneSettingsFn(paneId),
519
528
 
520
529
  on: <K extends keyof PluginEvents>(event: K, handler: (payload: PluginEvents[K]) => void) => {
@@ -526,7 +535,7 @@ export class PluginRegistry implements PluginRuntimeAccess {
526
535
 
527
536
  showWidget: (widgetId) => this.showWidget(widgetId),
528
537
  hideWidget: (widgetId) => this.hideWidget(widgetId),
529
- showToast: (message, options) => this.showToastFn(message, options),
538
+ notify: (notification) => this.notifyFn(notification),
530
539
  };
531
540
  }
532
541
 
@@ -1293,7 +1293,8 @@ describe("ProviderRouter", () => {
1293
1293
  test("drops stale cached cloud quotes while preserving cached cloud fundamentals", () => {
1294
1294
  const dbPath = createTempDbPath("stale-cloud-cached-financials");
1295
1295
  const persistence = new AppPersistence(dbPath);
1296
- const now = Date.parse("2026-04-08T10:55:00Z");
1296
+ const now = Date.now();
1297
+ const previousSession = now - 24 * 60 * 60_000;
1297
1298
 
1298
1299
  persistence.resources.set(
1299
1300
  {
@@ -1313,7 +1314,7 @@ describe("ProviderRouter", () => {
1313
1314
  currency: "EUR",
1314
1315
  change: 28,
1315
1316
  changePercent: 5.6,
1316
- lastUpdated: Date.parse("2026-04-07T19:55:00Z"),
1317
+ lastUpdated: previousSession,
1317
1318
  marketState: "REGULAR",
1318
1319
  exchangeName: "FWB2",
1319
1320
  listingExchangeName: "FWB2",
@@ -507,9 +507,21 @@ export class SecEdgarClient {
507
507
  }
508
508
 
509
509
  async getFilingContent(filing: Pick<SecFilingItem, "primaryDocumentUrl" | "filingUrl" | "form">): Promise<string | null> {
510
- const targetUrl = filing.primaryDocumentUrl || filing.filingUrl;
510
+ let targetUrl = filing.primaryDocumentUrl || filing.filingUrl;
511
511
  if (!targetUrl) return null;
512
512
 
513
+ // SEC ownership forms (3/4/5) have XSL-prefixed primaryDocument paths
514
+ // (e.g., "xslF345X06/wk-form4_xxx.xml") which return rendered HTML.
515
+ // Strip the prefix and return the raw XML for programmatic parsing.
516
+ const ownershipForms = new Set(["3", "4", "5"]);
517
+ if (filing.primaryDocumentUrl && filing.form && ownershipForms.has(filing.form.trim())) {
518
+ const rawUrl = filing.primaryDocumentUrl.replace(/\/xsl[^/]+\//, "/");
519
+ if (rawUrl !== filing.primaryDocumentUrl) {
520
+ const { body } = await this.fetchText(rawUrl);
521
+ return body;
522
+ }
523
+ }
524
+
513
525
  if (filing.primaryDocumentUrl && isPdfDocument("", "", filing.primaryDocumentUrl)) {
514
526
  const alternativeUrl = filing.filingUrl
515
527
  ? await this.findAlternativeHtmlDocumentUrl(filing.filingUrl, filing.primaryDocumentUrl)
@@ -1,5 +1,5 @@
1
1
  import type { Quote, Fundamentals, FinancialStatement, PricePoint, TickerFinancials, MarketState, OptionContract, OptionsChain, CompanyProfile } from "../types/financials";
2
- import type { DataProvider, MarketDataRequestContext, NewsItem, SecFilingItem } from "../types/data-provider";
2
+ import type { DataProvider, EarningsEvent, MarketDataRequestContext, NewsItem, SecFilingItem } from "../types/data-provider";
3
3
  import type { TimeRange } from "../components/chart/chart-types";
4
4
  import {
5
5
  normalizeChartResolutionSupport,
@@ -1065,4 +1065,78 @@ export class YahooFinanceClient implements DataProvider {
1065
1065
  }
1066
1066
  throw lastError || new Error(`No options chain for ${ticker}`);
1067
1067
  }
1068
+
1069
+ async getEarningsCalendar(symbols: string[], _context?: MarketDataRequestContext): Promise<EarningsEvent[]> {
1070
+ const results: EarningsEvent[] = [];
1071
+ const BATCH_SIZE = 5;
1072
+
1073
+ for (let i = 0; i < symbols.length; i += BATCH_SIZE) {
1074
+ if (i > 0) await new Promise((r) => setTimeout(r, 200));
1075
+ const batch = symbols.slice(i, i + BATCH_SIZE);
1076
+
1077
+ const settled = await Promise.allSettled(
1078
+ batch.map(async (symbol) => {
1079
+ const params = new URLSearchParams({ modules: "calendarEvents,earningsTrend,quoteType" });
1080
+ const url = `https://query1.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?${params}`;
1081
+ const data = await this.fetchJsonWithCrumb<{
1082
+ quoteSummary?: {
1083
+ result?: Array<{
1084
+ calendarEvents?: {
1085
+ earnings?: {
1086
+ earningsDate?: Array<{ raw: number }>;
1087
+ earningsAverage?: { raw: number };
1088
+ revenueAverage?: { raw: number };
1089
+ };
1090
+ };
1091
+ earningsTrend?: {
1092
+ trend?: Array<{
1093
+ period: string;
1094
+ earningsEstimate?: { avg?: { raw: number } };
1095
+ revenueEstimate?: { avg?: { raw: number } };
1096
+ }>;
1097
+ };
1098
+ quoteType?: {
1099
+ shortName?: string;
1100
+ longName?: string;
1101
+ };
1102
+ }>;
1103
+ };
1104
+ }>(`earnings ${symbol}`, url);
1105
+
1106
+ const mod = data.quoteSummary?.result?.[0];
1107
+ if (!mod) return null;
1108
+
1109
+ const cal = mod.calendarEvents?.earnings;
1110
+ if (!cal?.earningsDate?.length) return null;
1111
+
1112
+ const earningsDate = new Date(cal.earningsDate[0]!.raw * 1000);
1113
+ if (isNaN(earningsDate.getTime())) return null;
1114
+
1115
+ const currentQtr = mod.earningsTrend?.trend?.find((t) => t.period === "0q");
1116
+ const name = mod.quoteType?.shortName || mod.quoteType?.longName || symbol;
1117
+
1118
+ return {
1119
+ symbol,
1120
+ name,
1121
+ earningsDate,
1122
+ epsEstimate: currentQtr?.earningsEstimate?.avg?.raw ?? cal.earningsAverage?.raw ?? null,
1123
+ epsActual: null,
1124
+ revenueEstimate: currentQtr?.revenueEstimate?.avg?.raw ?? cal.revenueAverage?.raw ?? null,
1125
+ revenueActual: null,
1126
+ surprise: null,
1127
+ timing: "" as const,
1128
+ };
1129
+ }),
1130
+ );
1131
+
1132
+ for (const result of settled) {
1133
+ if (result.status === "fulfilled" && result.value) {
1134
+ results.push(result.value);
1135
+ }
1136
+ }
1137
+ }
1138
+
1139
+ results.sort((a, b) => a.earningsDate.getTime() - b.earningsDate.getTime());
1140
+ return results;
1141
+ }
1068
1142
  }
@@ -3,7 +3,7 @@ import { act, useRef, type Dispatch } from "react";
3
3
  import { testRender } from "@opentui/react/test-utils";
4
4
  import { AppProvider, PaneInstanceProvider, useAppDispatch, useAppSelector, usePaneTicker, type AppAction } from "./app-context";
5
5
  import { cloneLayout, createDefaultConfig, type AppConfig } from "../types/config";
6
- import { getCurrentThemeId } from "../theme/colors";
6
+ import { applyTheme, getCurrentThemeId } from "../theme/colors";
7
7
 
8
8
  const TEST_PANE_ID = "ticker-detail:test";
9
9
 
@@ -51,6 +51,7 @@ describe("pane selectors", () => {
51
51
  testSetup?.renderer.destroy();
52
52
  testSetup = undefined;
53
53
  capturedDispatch = null;
54
+ applyTheme("amber");
54
55
  });
55
56
 
56
57
  test("does not rerender usePaneTicker consumers for unrelated app state updates", async () => {
@@ -96,4 +97,19 @@ describe("pane selectors", () => {
96
97
  await testSetup.renderOnce();
97
98
  expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
98
99
  });
100
+
101
+ test("uses the configured theme on the first provider render", async () => {
102
+ const config = createTickerDetailConfig("AAPL");
103
+ config.theme = "green";
104
+
105
+ testSetup = await testRender(
106
+ <AppProvider config={config}>
107
+ <ThemeSelectorHarness />
108
+ </AppProvider>,
109
+ { width: 32, height: 4 },
110
+ );
111
+
112
+ await testSetup.renderOnce();
113
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
114
+ });
99
115
  });
@@ -22,6 +22,30 @@ describe("appReducer command bar state", () => {
22
22
  expect(next.commandBarQuery).toBe("");
23
23
  });
24
24
 
25
+ test("stores and clears command bar launch requests", () => {
26
+ const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
27
+ const opened = appReducer(initial, {
28
+ type: "SET_COMMAND_BAR",
29
+ open: true,
30
+ launch: { kind: "plugin-command", commandId: "set-alert" },
31
+ });
32
+ const relaunched = appReducer(opened, {
33
+ type: "SET_COMMAND_BAR",
34
+ open: true,
35
+ launch: { kind: "plugin-command", commandId: "set-alert" },
36
+ });
37
+ const closed = appReducer(relaunched, { type: "SET_COMMAND_BAR", open: false });
38
+
39
+ expect(opened.commandBarOpen).toBe(true);
40
+ expect(opened.commandBarLaunchRequest).toEqual({
41
+ kind: "plugin-command",
42
+ commandId: "set-alert",
43
+ sequence: 1,
44
+ });
45
+ expect(relaunched.commandBarLaunchRequest?.sequence).toBe(2);
46
+ expect(closed.commandBarLaunchRequest).toBeNull();
47
+ });
48
+
25
49
  test("toggle opens blank and clears on close", () => {
26
50
  const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
27
51
  const opened = appReducer(initial, { type: "TOGGLE_COMMAND_BAR" });
@@ -13,7 +13,7 @@ import {
13
13
  } from "react";
14
14
  import type { SessionStore } from "../data/session-store";
15
15
  import { saveConfig } from "../data/config-store";
16
- import { applyTheme } from "../theme/colors";
16
+ import { applyTheme, syncTheme } from "../theme/colors";
17
17
  import { isBrokerPortfolioId } from "../utils/broker-instances";
18
18
  import { resolveTickerFinancialsQuoteState } from "../utils/quote-resolution";
19
19
  import {
@@ -61,6 +61,12 @@ export interface CollectionSortPreference {
61
61
  direction: SortDirection;
62
62
  }
63
63
 
64
+ export interface CommandBarLaunchRequest {
65
+ kind: "plugin-command";
66
+ commandId: string;
67
+ sequence: number;
68
+ }
69
+
64
70
  export interface AppState {
65
71
  config: AppConfig;
66
72
  tickers: Map<string, TickerRecord>;
@@ -73,6 +79,7 @@ export interface AppState {
73
79
  recentTickers: string[];
74
80
  commandBarOpen: boolean;
75
81
  commandBarQuery: string;
82
+ commandBarLaunchRequest: CommandBarLaunchRequest | null;
76
83
  refreshing: Set<string>;
77
84
  initialized: boolean;
78
85
  statusBarVisible: boolean;
@@ -97,7 +104,12 @@ export type AppAction =
97
104
  | { type: "TRACK_TICKER"; symbol: string | null }
98
105
  | { type: "SET_ACTIVE_PANEL"; panel: "left" | "right" }
99
106
  | { type: "TOGGLE_COMMAND_BAR" }
100
- | { type: "SET_COMMAND_BAR"; open: boolean; query?: string }
107
+ | {
108
+ type: "SET_COMMAND_BAR";
109
+ open: boolean;
110
+ query?: string;
111
+ launch?: { kind: "plugin-command"; commandId: string } | null;
112
+ }
101
113
  | { type: "SET_COMMAND_BAR_QUERY"; query: string }
102
114
  | { type: "SET_REFRESHING"; symbol: string; refreshing: boolean }
103
115
  | { type: "SET_BROKER_ACCOUNTS"; instanceId: string; accounts: BrokerAccount[] }
@@ -492,15 +504,23 @@ export function appReducer(state: AppState, action: AppAction): AppState {
492
504
 
493
505
  case "TOGGLE_COMMAND_BAR":
494
506
  return state.commandBarOpen
495
- ? { ...state, commandBarOpen: false, commandBarQuery: "" }
496
- : { ...state, commandBarOpen: true, commandBarQuery: "" };
497
-
498
- case "SET_COMMAND_BAR":
507
+ ? { ...state, commandBarOpen: false, commandBarQuery: "", commandBarLaunchRequest: null }
508
+ : { ...state, commandBarOpen: true, commandBarQuery: "", commandBarLaunchRequest: null };
509
+
510
+ case "SET_COMMAND_BAR": {
511
+ const launchRequest = action.open && action.launch
512
+ ? {
513
+ ...action.launch,
514
+ sequence: (state.commandBarLaunchRequest?.sequence ?? 0) + 1,
515
+ }
516
+ : null;
499
517
  return {
500
518
  ...state,
501
519
  commandBarOpen: action.open,
502
520
  commandBarQuery: action.open ? (action.query ?? "") : "",
521
+ commandBarLaunchRequest: launchRequest,
503
522
  };
523
+ }
504
524
 
505
525
  case "SET_COMMAND_BAR_QUERY":
506
526
  return { ...state, commandBarQuery: action.query };
@@ -1018,6 +1038,7 @@ export function createInitialState(config: AppConfig, sessionSnapshot: AppSessio
1018
1038
  recentTickers: config.recentTickers,
1019
1039
  commandBarOpen: false,
1020
1040
  commandBarQuery: "",
1041
+ commandBarLaunchRequest: null,
1021
1042
  refreshing: new Set(),
1022
1043
  initialized: false,
1023
1044
  statusBarVisible: sessionSnapshot?.statusBarVisible !== false,
@@ -1043,6 +1064,8 @@ export function AppProvider({
1043
1064
  sessionStore?: SessionStore;
1044
1065
  sessionSnapshot?: AppSessionSnapshot | null;
1045
1066
  }) {
1067
+ // Sync the configured theme before descendants read the shared palette on first render.
1068
+ syncTheme(config.theme);
1046
1069
  const [state, dispatch] = useReducer(appReducer, { config, sessionSnapshot }, ({ config, sessionSnapshot: initialSessionSnapshot }) => (
1047
1070
  createInitialState(config, initialSessionSnapshot)
1048
1071
  ));
@@ -18,6 +18,11 @@ export function applyTheme(id: string): void {
18
18
  Object.assign(colors, theme);
19
19
  }
20
20
 
21
+ export function syncTheme(id: string): void {
22
+ if (currentThemeId === id) return;
23
+ applyTheme(id);
24
+ }
25
+
21
26
  export type ColorKey = keyof typeof colors;
22
27
 
23
28
  export { blendHex } from "./color-utils";