gloomberb 0.7.0 → 0.7.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 (125) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +63 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +6 -0
  118. package/src/utils/api-client.test.ts +32 -0
  119. package/src/utils/api-client.ts +119 -18
  120. package/src/utils/double-escape-close.test.ts +33 -0
  121. package/src/utils/double-escape-close.ts +40 -0
  122. package/src/utils/throttled-fetch.test.ts +22 -0
  123. package/src/utils/throttled-fetch.ts +3 -1
  124. package/src/utils/ticker-search.ts +53 -9
  125. package/src/version.ts +1 -1
@@ -12,10 +12,12 @@ import type {
12
12
  import type { InstrumentSearchResult } from "../types/instrument";
13
13
  import { debugLog } from "./debug-log";
14
14
  import { canonicalExchange, normalizeSymbol, publicTickerKey } from "./exchanges";
15
+ import { httpFetch } from "./http-transport";
15
16
  import { normalizeTimestamp } from "./timestamp";
16
17
 
17
18
  const DEFAULT_API_URL = "https://api.gloom.sh";
18
19
  const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
20
+ const QUOTE_SUBSCRIPTION_FLUSH_MS = 25;
19
21
  const cloudApiLog = debugLog.createLogger("cloud-api");
20
22
  const HARD_SESSION_INVALID_PATTERNS = [
21
23
  /\b(user|account)\b.*\b(not found|deleted|removed|disabled|deactivated|suspended)\b/i,
@@ -26,10 +28,10 @@ const HARD_SESSION_INVALID_PATTERNS = [
26
28
  type CloudApiResponse = Pick<Response, "ok" | "status" | "headers" | "text">;
27
29
  type CloudApiFetchTransport = (url: string, init?: RequestInit) => Promise<CloudApiResponse>;
28
30
 
29
- let cloudApiFetchTransport: CloudApiFetchTransport = (url, init) => fetch(url, init);
31
+ let cloudApiFetchTransport: CloudApiFetchTransport = httpFetch;
30
32
 
31
33
  export function setCloudApiFetchTransport(transport: CloudApiFetchTransport | null): void {
32
- cloudApiFetchTransport = transport ?? ((url, init) => fetch(url, init));
34
+ cloudApiFetchTransport = transport ?? httpFetch;
33
35
  }
34
36
 
35
37
  function getCloudApiBaseUrl(): string {
@@ -54,7 +56,6 @@ export interface ChatMessage {
54
56
  export interface ChatChannel {
55
57
  id: string;
56
58
  name: string;
57
- description: string | null;
58
59
  created_at: string;
59
60
  }
60
61
 
@@ -306,6 +307,23 @@ export interface CloudMarketResponse<T> {
306
307
  };
307
308
  }
308
309
 
310
+ export interface CloudMarketBatchTarget {
311
+ symbol: string;
312
+ exchange?: string;
313
+ }
314
+
315
+ export interface CloudMarketBatchItem<T> {
316
+ symbol: string;
317
+ exchange: string;
318
+ status: CloudMarketStatus;
319
+ data: T | null;
320
+ reasonCode?: string;
321
+ }
322
+
323
+ export interface CloudMarketBatchPayload<T> {
324
+ items: Array<CloudMarketBatchItem<T>>;
325
+ }
326
+
309
327
  export interface CloudVerificationResponse {
310
328
  sent: boolean;
311
329
  email?: string;
@@ -392,6 +410,9 @@ class GloomApiClient {
392
410
  private readonly channelListeners = new Map<string, Set<ChannelListener>>();
393
411
  private readonly quoteListeners = new Map<string, Set<QuoteListener>>();
394
412
  private readonly quoteTargets = new Map<string, QuoteStreamTarget>();
413
+ private readonly pendingQuoteSubscribes = new Map<string, QuoteStreamTarget>();
414
+ private readonly pendingQuoteUnsubscribes = new Map<string, QuoteStreamTarget>();
415
+ private quoteSubscriptionFlushTimer: ReturnType<typeof setTimeout> | null = null;
395
416
 
396
417
  constructor() {
397
418
  this.baseUrl = getCloudApiBaseUrl();
@@ -467,7 +488,7 @@ class GloomApiClient {
467
488
  throw new Error(message);
468
489
  }
469
490
 
470
- private extractSessionCookie(res: Response): void {
491
+ private extractSessionCookie(res: CloudApiResponse): void {
471
492
  const setCookie = res.headers.getSetCookie?.() ?? [];
472
493
  const fallbackHeader = res.headers.get("set-cookie");
473
494
  if (fallbackHeader) {
@@ -654,6 +675,62 @@ class GloomApiClient {
654
675
  }
655
676
  }
656
677
 
678
+ private scheduleQuoteSubscriptionFlush(): void {
679
+ if (this.quoteSubscriptionFlushTimer) return;
680
+ this.quoteSubscriptionFlushTimer = setTimeout(() => {
681
+ this.quoteSubscriptionFlushTimer = null;
682
+ this.flushQueuedQuoteSubscriptions();
683
+ }, QUOTE_SUBSCRIPTION_FLUSH_MS);
684
+ }
685
+
686
+ private queueQuoteSubscribes(targets: QuoteStreamTarget[]): void {
687
+ for (const target of targets) {
688
+ const key = marketKey(target.symbol, target.exchange);
689
+ this.pendingQuoteUnsubscribes.delete(key);
690
+ this.pendingQuoteSubscribes.set(key, target);
691
+ }
692
+ this.scheduleQuoteSubscriptionFlush();
693
+ }
694
+
695
+ private queueQuoteUnsubscribes(targets: QuoteStreamTarget[]): void {
696
+ for (const target of targets) {
697
+ const key = marketKey(target.symbol, target.exchange);
698
+ if (this.pendingQuoteSubscribes.delete(key)) continue;
699
+ this.pendingQuoteUnsubscribes.set(key, target);
700
+ }
701
+ this.scheduleQuoteSubscriptionFlush();
702
+ }
703
+
704
+ private flushQueuedQuoteSubscriptions(): void {
705
+ if (this.ws?.readyState !== WebSocket.OPEN) {
706
+ this.pendingQuoteSubscribes.clear();
707
+ this.pendingQuoteUnsubscribes.clear();
708
+ return;
709
+ }
710
+ const subscribes = [...this.pendingQuoteSubscribes.values()];
711
+ const unsubscribes = [...this.pendingQuoteUnsubscribes.values()];
712
+ this.pendingQuoteSubscribes.clear();
713
+ this.pendingQuoteUnsubscribes.clear();
714
+ if (subscribes.length > 0) {
715
+ this.sendSocketMessage({
716
+ type: "market.subscribe",
717
+ symbols: subscribes.map((target) => ({
718
+ symbol: target.symbol,
719
+ exchange: target.exchange ?? "",
720
+ })),
721
+ });
722
+ }
723
+ if (unsubscribes.length > 0) {
724
+ this.sendSocketMessage({
725
+ type: "market.unsubscribe",
726
+ symbols: unsubscribes.map((target) => ({
727
+ symbol: target.symbol,
728
+ exchange: target.exchange ?? "",
729
+ })),
730
+ });
731
+ }
732
+ }
733
+
657
734
  private async handleSocketMessage(raw: string): Promise<void> {
658
735
  let parsed: any;
659
736
  try {
@@ -879,13 +956,7 @@ class GloomApiClient {
879
956
  count: newSubscriptions.length,
880
957
  symbols: newSubscriptions.map((target) => marketKey(target.symbol, target.exchange)),
881
958
  });
882
- this.sendSocketMessage({
883
- type: "market.subscribe",
884
- symbols: newSubscriptions.map((target) => ({
885
- symbol: target.symbol,
886
- exchange: target.exchange ?? "",
887
- })),
888
- });
959
+ this.queueQuoteSubscribes(newSubscriptions);
889
960
  }
890
961
 
891
962
  return () => {
@@ -911,13 +982,7 @@ class GloomApiClient {
911
982
  count: removedTargets.length,
912
983
  symbols: removedTargets.map((target) => marketKey(target.symbol, target.exchange)),
913
984
  });
914
- this.sendSocketMessage({
915
- type: "market.unsubscribe",
916
- symbols: removedTargets.map((target) => ({
917
- symbol: target.symbol,
918
- exchange: target.exchange ?? "",
919
- })),
920
- });
985
+ this.queueQuoteUnsubscribes(removedTargets);
921
986
  }
922
987
 
923
988
  if (!this.shouldKeepSocketOpen()) {
@@ -934,6 +999,12 @@ class GloomApiClient {
934
999
  this.channelListeners.clear();
935
1000
  this.quoteListeners.clear();
936
1001
  this.quoteTargets.clear();
1002
+ this.pendingQuoteSubscribes.clear();
1003
+ this.pendingQuoteUnsubscribes.clear();
1004
+ if (this.quoteSubscriptionFlushTimer) {
1005
+ clearTimeout(this.quoteSubscriptionFlushTimer);
1006
+ this.quoteSubscriptionFlushTimer = null;
1007
+ }
937
1008
  this.reconnectDelayMs = 1000;
938
1009
  this.teardownSocket();
939
1010
  }
@@ -953,6 +1024,16 @@ class GloomApiClient {
953
1024
  return this.request<CloudMarketResponse<CloudQuotePayload>>(`/market/quote?${params.toString()}`);
954
1025
  }
955
1026
 
1027
+ async getCloudQuotesBatch(
1028
+ targets: CloudMarketBatchTarget[],
1029
+ mode: "cache-first" | "refresh" = "cache-first",
1030
+ ): Promise<CloudMarketResponse<CloudMarketBatchPayload<CloudQuotePayload>>> {
1031
+ return this.request<CloudMarketResponse<CloudMarketBatchPayload<CloudQuotePayload>>>("/market/quotes/batch", {
1032
+ method: "POST",
1033
+ body: JSON.stringify({ targets, mode }),
1034
+ });
1035
+ }
1036
+
956
1037
  async getCloudProfile(symbol: string, exchange?: string): Promise<CloudMarketResponse<CloudCompanyProfile>> {
957
1038
  const params = new URLSearchParams({ symbol });
958
1039
  if (exchange) params.set("exchange", exchange);
@@ -965,6 +1046,22 @@ class GloomApiClient {
965
1046
  return this.request<CloudMarketResponse<CloudFundamentals>>(`/market/fundamentals?${params.toString()}`);
966
1047
  }
967
1048
 
1049
+ async getCloudFinancials(symbol: string, exchange?: string): Promise<CloudMarketResponse<TickerFinancials>> {
1050
+ const params = new URLSearchParams({ symbol });
1051
+ if (exchange) params.set("exchange", exchange);
1052
+ return this.request<CloudMarketResponse<TickerFinancials>>(`/market/financials?${params.toString()}`);
1053
+ }
1054
+
1055
+ async getCloudFinancialsBatch(
1056
+ targets: CloudMarketBatchTarget[],
1057
+ mode: "cache-first" | "refresh" = "cache-first",
1058
+ ): Promise<CloudMarketResponse<CloudMarketBatchPayload<TickerFinancials>>> {
1059
+ return this.request<CloudMarketResponse<CloudMarketBatchPayload<TickerFinancials>>>("/market/financials/batch", {
1060
+ method: "POST",
1061
+ body: JSON.stringify({ targets, mode }),
1062
+ });
1063
+ }
1064
+
968
1065
  async getCloudHolders(symbol: string, exchange?: string): Promise<CloudMarketResponse<CloudHoldersPayload>> {
969
1066
  const params = new URLSearchParams({ symbol });
970
1067
  if (exchange) params.set("exchange", exchange);
@@ -1093,6 +1190,10 @@ class GloomApiClient {
1093
1190
  return this.request<CloudNewsListResponse>(`/news${query ? `?${query}` : ""}`);
1094
1191
  }
1095
1192
 
1193
+ async getCloudNewsStory(storyId: string): Promise<CloudNewsPayload> {
1194
+ return this.request<CloudNewsPayload>(`/news/${encodeURIComponent(storyId)}`);
1195
+ }
1196
+
1096
1197
  async getCloudTickerTweets(params: {
1097
1198
  ticker: string;
1098
1199
  limit?: number;
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createDoubleEscapeCloseState,
4
+ recordDoubleEscapeClose,
5
+ resetDoubleEscapeClose,
6
+ } from "./double-escape-close";
7
+
8
+ describe("double escape close detector", () => {
9
+ test("matches a second escape for the same target inside the threshold", () => {
10
+ const state = createDoubleEscapeCloseState();
11
+
12
+ expect(recordDoubleEscapeClose(state, "pane:a", 1000)).toBe(false);
13
+ expect(recordDoubleEscapeClose(state, "pane:a", 1200)).toBe(true);
14
+ expect(state.targetId).toBe(null);
15
+ });
16
+
17
+ test("requires the same target and a recent first escape", () => {
18
+ const state = createDoubleEscapeCloseState();
19
+
20
+ expect(recordDoubleEscapeClose(state, "pane:a", 1000)).toBe(false);
21
+ expect(recordDoubleEscapeClose(state, "pane:b", 1100)).toBe(false);
22
+ expect(recordDoubleEscapeClose(state, "pane:b", 2000)).toBe(false);
23
+ expect(recordDoubleEscapeClose(state, "pane:b", 2100)).toBe(true);
24
+ });
25
+
26
+ test("can be reset by non-escape input", () => {
27
+ const state = createDoubleEscapeCloseState();
28
+
29
+ expect(recordDoubleEscapeClose(state, "pane:a", 1000)).toBe(false);
30
+ resetDoubleEscapeClose(state);
31
+ expect(recordDoubleEscapeClose(state, "pane:a", 1100)).toBe(false);
32
+ });
33
+ });
@@ -0,0 +1,40 @@
1
+ export const DOUBLE_ESCAPE_CLOSE_MS = 650;
2
+
3
+ export interface DoubleEscapeCloseState {
4
+ lastAt: number;
5
+ targetId: string | null;
6
+ }
7
+
8
+ export function createDoubleEscapeCloseState(): DoubleEscapeCloseState {
9
+ return {
10
+ lastAt: 0,
11
+ targetId: null,
12
+ };
13
+ }
14
+
15
+ export function resetDoubleEscapeClose(state: DoubleEscapeCloseState) {
16
+ state.lastAt = 0;
17
+ state.targetId = null;
18
+ }
19
+
20
+ export function recordDoubleEscapeClose(
21
+ state: DoubleEscapeCloseState,
22
+ targetId: string | null | undefined,
23
+ now: number,
24
+ thresholdMs = DOUBLE_ESCAPE_CLOSE_MS,
25
+ ): boolean {
26
+ if (!targetId) {
27
+ resetDoubleEscapeClose(state);
28
+ return false;
29
+ }
30
+
31
+ const matched = state.targetId === targetId && now - state.lastAt <= thresholdMs;
32
+ if (matched) {
33
+ resetDoubleEscapeClose(state);
34
+ return true;
35
+ }
36
+
37
+ state.targetId = targetId;
38
+ state.lastAt = now;
39
+ return false;
40
+ }
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test";
2
2
  import { createThrottledFetch } from "./throttled-fetch";
3
+ import { setHttpFetchTransport } from "./http-transport";
3
4
 
4
5
  // Mock global fetch
5
6
  const originalFetch = globalThis.fetch;
6
7
  let fetchMock: ReturnType<typeof mock>;
7
8
 
8
9
  beforeEach(() => {
10
+ setHttpFetchTransport(null);
9
11
  fetchMock = mock(() => Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })));
10
12
  globalThis.fetch = fetchMock as any;
11
13
  });
@@ -156,9 +158,29 @@ describe("createThrottledFetch", () => {
156
158
  expect(transportMock).toHaveBeenCalledTimes(1);
157
159
  expect(fetchMock).not.toHaveBeenCalled();
158
160
  });
161
+
162
+ test("uses the shared HTTP transport by default", async () => {
163
+ const transportMock = mock((url: string, init?: RequestInit) => {
164
+ expect(url).toBe("https://api.example.com/shared");
165
+ expect((init?.headers as Record<string, string>)["X-Shared"]).toBe("1");
166
+ return Promise.resolve(new Response("shared", { status: 203 }));
167
+ });
168
+ setHttpFetchTransport(transportMock);
169
+
170
+ const client = createThrottledFetch({
171
+ defaultHeaders: { "X-Shared": "1" },
172
+ });
173
+ const resp = await client.fetch("https://api.example.com/shared");
174
+
175
+ expect(resp.status).toBe(203);
176
+ expect(await resp.text()).toBe("shared");
177
+ expect(transportMock).toHaveBeenCalledTimes(1);
178
+ expect(fetchMock).not.toHaveBeenCalled();
179
+ });
159
180
  });
160
181
 
161
182
  // Restore
162
183
  afterAll(() => {
184
+ setHttpFetchTransport(null);
163
185
  globalThis.fetch = originalFetch;
164
186
  });
@@ -7,6 +7,8 @@
7
7
  * const data = await client.fetch("https://api.example.com/data");
8
8
  */
9
9
 
10
+ import { httpFetch } from "./http-transport";
11
+
10
12
  const DEFAULT_REQUESTS_PER_MINUTE = 30;
11
13
  const DEFAULT_MAX_RETRIES = 2;
12
14
  const DEFAULT_TIMEOUT_MS = 10_000;
@@ -50,7 +52,7 @@ export function createThrottledFetch(
50
52
  const defaultHeaders = options.defaultHeaders ?? {};
51
53
  const dedupeGetRequests = options.dedupeGetRequests ?? true;
52
54
  const fetchTransport =
53
- options.transport ?? ((url: string, init?: RequestInit) => globalThis.fetch(url, init));
55
+ options.transport ?? httpFetch;
54
56
 
55
57
  // Sliding window: track timestamps of recent requests per host
56
58
  const hostTimestamps = new Map<string, number[]>();
@@ -3,12 +3,14 @@ import type { InstrumentSearchResult } from "../types/instrument";
3
3
  import type { TickerRecord } from "../types/ticker";
4
4
  import type { TickerMetadata } from "../types/ticker";
5
5
  import type { TickerRepository } from "../data/ticker-repository";
6
+ import { parseOptionSymbol } from "./options";
6
7
 
7
8
  export type TickerSearchInstrumentClass = "equity" | "fund" | "derivative" | "other";
8
9
  export type TickerSearchCategory = "Saved" | "Primary Listing" | "Other Listings" | "Funds & Derivatives";
9
10
 
10
11
  const FUND_TYPES = new Set(["ETF", "ETN", "ETP", "FUND", "MUTUALFUND", "CEF", "CLOSEDEND"]);
11
12
  const DERIVATIVE_TYPES = new Set(["OPT", "OPTION", "OPTIONS", "FUT", "FUTURE", "FUTURES", "WARRANT", "WARRANTS", "RIGHT", "RIGHTS"]);
13
+ const OPTION_TYPES = new Set(["OPT", "OPTION", "OPTIONS"]);
12
14
  const EQUITY_TYPES = new Set(["STK", "STOCK", "EQUITY", "COMMONSTOCK", "COMMON STOCK", "ADR", "ORDINARYSHARES", "ORDINARY SHARES"]);
13
15
 
14
16
  const EXCHANGE_HINT_ALIASES: Record<string, string[]> = {
@@ -90,6 +92,10 @@ interface SearchQueryIntent {
90
92
  assetPreference: TickerSearchInstrumentClass | null;
91
93
  }
92
94
 
95
+ interface TickerSearchCandidateOptions {
96
+ includeOptionContracts?: boolean;
97
+ }
98
+
93
99
  export function normalizeTickerInput(activeTicker: string | null, arg?: string): string | null {
94
100
  const explicitTicker = arg?.trim().toUpperCase();
95
101
  if (explicitTicker) return explicitTicker;
@@ -99,11 +105,13 @@ export function normalizeTickerInput(activeTicker: string | null, arg?: string):
99
105
  export function createLocalTickerSearchCandidates(
100
106
  tickers: Iterable<TickerRecord>,
101
107
  providerHints: ReadonlyMap<string, InstrumentSearchResult> = new Map(),
108
+ options: TickerSearchCandidateOptions = {},
102
109
  ): TickerSearchCandidate[] {
103
- return Array.from(tickers).map((ticker) => {
110
+ return Array.from(tickers).flatMap((ticker) => {
111
+ if (options.includeOptionContracts === false && isOptionTickerRecord(ticker)) return [];
104
112
  const symbol = normalizeTickerSymbol(ticker.metadata.ticker);
105
113
  const hint = providerHints.get(symbol);
106
- return {
114
+ return [{
107
115
  id: `goto:${ticker.metadata.ticker}`,
108
116
  label: ticker.metadata.ticker,
109
117
  symbol,
@@ -118,18 +126,20 @@ export function createLocalTickerSearchCandidates(
118
126
  searchAliases: buildSymbolAliases(symbol),
119
127
  ticker,
120
128
  result: hint,
121
- };
129
+ }];
122
130
  });
123
131
  }
124
132
 
125
133
  export function createProviderTickerSearchCandidates(
126
134
  searchResults: InstrumentSearchResult[],
127
135
  localTickers: ReadonlyMap<string, TickerRecord>,
136
+ options: TickerSearchCandidateOptions = {},
128
137
  ): TickerSearchCandidate[] {
129
- return searchResults.map((result) => {
138
+ return searchResults.flatMap((result) => {
139
+ if (options.includeOptionContracts === false && isOptionSearchResult(result)) return [];
130
140
  const symbol = getSearchResultSymbol(result);
131
141
  const saved = localTickers.has(symbol);
132
- return {
142
+ return [{
133
143
  id: `search:${result.symbol}`,
134
144
  label: symbol,
135
145
  symbol,
@@ -143,7 +153,7 @@ export function createProviderTickerSearchCandidates(
143
153
  instrumentClass: classifyInstrumentKind(result.brokerContract?.secType || result.type),
144
154
  searchAliases: buildSearchResultAliases(result),
145
155
  result,
146
- };
156
+ }];
147
157
  });
148
158
  }
149
159
 
@@ -154,6 +164,7 @@ export async function searchTickerCandidates({
154
164
  searchContext,
155
165
  localLimit = 6,
156
166
  totalLimit = 8,
167
+ includeOptionContracts = true,
157
168
  }: {
158
169
  query: string;
159
170
  tickers: ReadonlyMap<string, TickerRecord>;
@@ -161,6 +172,7 @@ export async function searchTickerCandidates({
161
172
  searchContext?: SearchRequestContext;
162
173
  localLimit?: number;
163
174
  totalLimit?: number;
175
+ includeOptionContracts?: boolean;
164
176
  }): Promise<TickerSearchCandidate[]> {
165
177
  return buildTickerSearchCandidates({
166
178
  query,
@@ -168,6 +180,7 @@ export async function searchTickerCandidates({
168
180
  providerResults: await searchProviderResults(dataProvider, query, searchContext),
169
181
  localLimit,
170
182
  totalLimit,
183
+ includeOptionContracts,
171
184
  });
172
185
  }
173
186
 
@@ -177,19 +190,25 @@ export function buildTickerSearchCandidates({
177
190
  providerResults,
178
191
  localLimit = 6,
179
192
  totalLimit = 8,
193
+ includeOptionContracts = true,
180
194
  }: {
181
195
  query: string;
182
196
  tickers: ReadonlyMap<string, TickerRecord>;
183
197
  providerResults: InstrumentSearchResult[];
184
198
  localLimit?: number;
185
199
  totalLimit?: number;
200
+ includeOptionContracts?: boolean;
186
201
  }): TickerSearchCandidate[] {
187
- const providerHints = buildProviderHintMap(providerResults);
202
+ const candidateOptions = { includeOptionContracts };
203
+ const filteredProviderResults = includeOptionContracts
204
+ ? providerResults
205
+ : providerResults.filter((result) => !isOptionSearchResult(result));
206
+ const providerHints = buildProviderHintMap(filteredProviderResults);
188
207
  const localItems = rankTickerSearchItems(
189
- createLocalTickerSearchCandidates(tickers.values(), providerHints),
208
+ createLocalTickerSearchCandidates(tickers.values(), providerHints, candidateOptions),
190
209
  query,
191
210
  );
192
- const providerItems = createProviderTickerSearchCandidates(providerResults, tickers);
211
+ const providerItems = createProviderTickerSearchCandidates(filteredProviderResults, tickers, candidateOptions);
193
212
  const ranked = rankTickerSearchItems([...localItems, ...providerItems], query);
194
213
  return limitTickerSearchCandidates(assignTickerSearchCategories(ranked), totalLimit, localLimit);
195
214
  }
@@ -551,6 +570,31 @@ function shouldReplaceAssetCategory(currentCategory: string | undefined, nextCat
551
570
  return currentClass === "equity" && nextClass !== "equity";
552
571
  }
553
572
 
573
+ function isOptionTickerRecord(ticker: TickerRecord): boolean {
574
+ return isOptionType(ticker.metadata.assetCategory)
575
+ || parseOptionSymbol(ticker.metadata.ticker) != null
576
+ || (ticker.metadata.broker_contracts ?? []).some(isOptionBrokerContract);
577
+ }
578
+
579
+ function isOptionSearchResult(result: InstrumentSearchResult): boolean {
580
+ return isOptionType(result.type)
581
+ || parseOptionSymbol(result.symbol) != null
582
+ || isOptionBrokerContract(result.brokerContract);
583
+ }
584
+
585
+ function isOptionBrokerContract(contract: InstrumentSearchResult["brokerContract"]): boolean {
586
+ if (!contract) return false;
587
+ return isOptionType(contract.secType)
588
+ || parseOptionSymbol(contract.localSymbol || "") != null
589
+ || contract.right === "C"
590
+ || contract.right === "P"
591
+ || contract.strike != null;
592
+ }
593
+
594
+ function isOptionType(rawType?: string): boolean {
595
+ return OPTION_TYPES.has(normalizeSearchText(rawType || ""));
596
+ }
597
+
554
598
  function analyzeSearchQuery(query: string): SearchQueryIntent {
555
599
  const normalizedQuery = normalizeSearchText(query);
556
600
  const compactQuery = compactSearchText(query);
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.7.0";
1
+ export const VERSION = "0.7.1";