gloomberb 0.4.1 → 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 (207) 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/header.tsx +2 -1
  48. package/src/components/layout/shell.test.tsx +186 -27
  49. package/src/components/layout/shell.tsx +5 -4
  50. package/src/components/layout/status-bar.test.tsx +16 -7
  51. package/src/components/layout/status-bar.tsx +4 -1
  52. package/src/components/markdown-editor.tsx +1 -0
  53. package/src/components/pane-settings-dialog.tsx +13 -152
  54. package/src/components/pane-template-wizard.tsx +1 -0
  55. package/src/components/price-selector-dialog.tsx +12 -7
  56. package/src/components/ticker-badge-text.test.tsx +50 -0
  57. package/src/components/ticker-badge-text.tsx +17 -2
  58. package/src/components/ticker-badge.tsx +2 -2
  59. package/src/components/ticker-list-table.tsx +54 -1
  60. package/src/components/toggle-list.tsx +15 -2
  61. package/src/components/ui/data-table.tsx +60 -3
  62. package/src/components/ui/external-link.tsx +48 -11
  63. package/src/components/ui/fields.tsx +2 -1
  64. package/src/components/ui/index.ts +8 -1
  65. package/src/components/ui/multi-select-chips.tsx +69 -0
  66. package/src/components/ui/multi-select-dialog.tsx +267 -0
  67. package/src/components/ui/multi-select.ts +78 -0
  68. package/src/components/ui/ui.test.tsx +343 -1
  69. package/src/data/plugin-state-store.ts +30 -19
  70. package/src/data/resource-store.ts +133 -103
  71. package/src/data/session-store.ts +19 -12
  72. package/src/data/sqlite-cache.test.ts +37 -2
  73. package/src/data/sqlite-database.ts +8 -2
  74. package/src/data/sqlite-retry.test.ts +63 -0
  75. package/src/data/sqlite-retry.ts +66 -0
  76. package/src/data/ticker-store.ts +29 -18
  77. package/src/index.tsx +6 -1
  78. package/src/market-data/coordinator.test.ts +114 -5
  79. package/src/market-data/coordinator.ts +70 -1
  80. package/src/market-data/hooks.test.tsx +73 -2
  81. package/src/market-data/hooks.tsx +16 -4
  82. package/src/notifications/app-notifier.test.ts +177 -0
  83. package/src/notifications/app-notifier.ts +241 -0
  84. package/src/pane-settings.test.ts +2 -0
  85. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  86. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  87. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  88. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  89. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  90. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  91. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  92. package/src/plugins/builtin/alerts/index.tsx +519 -0
  93. package/src/plugins/builtin/alerts/types.ts +14 -0
  94. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  95. package/src/plugins/builtin/analytics/index.tsx +775 -0
  96. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  97. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  98. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  99. package/src/plugins/builtin/chat-controller.test.ts +62 -18
  100. package/src/plugins/builtin/chat-controller.ts +23 -12
  101. package/src/plugins/builtin/chat.test.tsx +715 -4
  102. package/src/plugins/builtin/chat.tsx +415 -119
  103. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  104. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  105. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  106. package/src/plugins/builtin/correlation/compute.ts +115 -0
  107. package/src/plugins/builtin/correlation/index.tsx +385 -0
  108. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  109. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  110. package/src/plugins/builtin/correlation/settings.ts +95 -0
  111. package/src/plugins/builtin/debug.tsx +36 -50
  112. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  113. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  114. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  115. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  116. package/src/plugins/builtin/earnings/index.tsx +337 -0
  117. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  118. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  119. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  120. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  121. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  122. package/src/plugins/builtin/econ/index.tsx +999 -0
  123. package/src/plugins/builtin/econ/types.ts +13 -0
  124. package/src/plugins/builtin/fred-settings.ts +10 -0
  125. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  126. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  127. package/src/plugins/builtin/help.test.tsx +1 -5
  128. package/src/plugins/builtin/help.tsx +1 -16
  129. package/src/plugins/builtin/insider/index.tsx +417 -0
  130. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  131. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  132. package/src/plugins/builtin/layout-manager.tsx +24 -16
  133. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  134. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  135. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  136. package/src/plugins/builtin/options.tsx +4 -3
  137. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  138. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  139. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  140. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  141. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  142. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  143. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  144. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  146. package/src/plugins/builtin/sec.tsx +114 -7
  147. package/src/plugins/builtin/sectors/index.tsx +359 -0
  148. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  149. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  150. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  151. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  152. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  153. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  154. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  155. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  156. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  157. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  158. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  159. package/src/plugins/catalog.ts +22 -0
  160. package/src/plugins/gridlock-notification.ts +15 -0
  161. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  162. package/src/plugins/ibkr/index.test.tsx +1 -1
  163. package/src/plugins/ibkr/index.tsx +1 -1
  164. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  165. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  166. package/src/plugins/ibkr/trade-utils.ts +7 -6
  167. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  168. package/src/plugins/plugin-runtime.test.tsx +2 -0
  169. package/src/plugins/plugin-runtime.tsx +10 -0
  170. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  171. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  172. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  173. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  174. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  175. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  176. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  177. package/src/plugins/registry.ts +22 -13
  178. package/src/sources/provider-router.test.ts +3 -2
  179. package/src/sources/sec-edgar.ts +13 -1
  180. package/src/sources/yahoo-finance.ts +75 -1
  181. package/src/state/app-context-selector.test.tsx +17 -1
  182. package/src/state/app-context.test.tsx +24 -0
  183. package/src/state/app-context.tsx +29 -6
  184. package/src/theme/colors.ts +5 -0
  185. package/src/theme/themes.ts +25 -0
  186. package/src/types/config.test.ts +107 -0
  187. package/src/types/config.ts +30 -1
  188. package/src/types/data-provider.ts +13 -0
  189. package/src/types/plugin.ts +35 -1
  190. package/src/utils/format.test.ts +11 -1
  191. package/src/utils/format.ts +86 -8
  192. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  193. package/src/utils/inline-content-tokenizer.ts +76 -0
  194. package/src/utils/link-tokenizer.test.ts +27 -0
  195. package/src/utils/link-tokenizer.ts +93 -0
  196. package/src/utils/market-format.test.ts +109 -0
  197. package/src/utils/market-format.ts +299 -0
  198. package/src/utils/market-status.ts +3 -2
  199. package/src/utils/selection-clipboard.test.ts +43 -0
  200. package/src/utils/selection-clipboard.ts +88 -0
  201. package/src/utils/terminal-input-reset.test.ts +50 -0
  202. package/src/utils/terminal-input-reset.ts +24 -0
  203. package/src/utils/throttled-fetch.test.ts +145 -0
  204. package/src/utils/throttled-fetch.ts +212 -0
  205. package/src/utils/ticker-tokenizer.test.ts +31 -0
  206. package/src/utils/ticker-tokenizer.ts +7 -42
  207. package/src/version.ts +1 -1
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resetTerminalInputState, TERMINAL_MOUSE_RESET_SEQUENCE } from "./terminal-input-reset";
3
+
4
+ describe("resetTerminalInputState", () => {
5
+ test("disables mouse tracking and exits raw mode before renderer startup", () => {
6
+ const writes: string[] = [];
7
+ const rawModeCalls: boolean[] = [];
8
+
9
+ resetTerminalInputState(
10
+ {
11
+ isTTY: true,
12
+ setRawMode(mode: boolean) {
13
+ rawModeCalls.push(mode);
14
+ },
15
+ },
16
+ {
17
+ isTTY: true,
18
+ write(chunk: string) {
19
+ writes.push(chunk);
20
+ },
21
+ },
22
+ );
23
+
24
+ expect(writes).toEqual([TERMINAL_MOUSE_RESET_SEQUENCE]);
25
+ expect(rawModeCalls).toEqual([false]);
26
+ });
27
+
28
+ test("does not write terminal escapes when stdout is not a tty", () => {
29
+ const writes: string[] = [];
30
+ const rawModeCalls: boolean[] = [];
31
+
32
+ resetTerminalInputState(
33
+ {
34
+ isTTY: true,
35
+ setRawMode(mode: boolean) {
36
+ rawModeCalls.push(mode);
37
+ },
38
+ },
39
+ {
40
+ isTTY: false,
41
+ write(chunk: string) {
42
+ writes.push(chunk);
43
+ },
44
+ },
45
+ );
46
+
47
+ expect(writes).toEqual([]);
48
+ expect(rawModeCalls).toEqual([false]);
49
+ });
50
+ });
@@ -0,0 +1,24 @@
1
+ export const TERMINAL_MOUSE_RESET_SEQUENCE = "\x1B[?1016l\x1B[?1006l\x1B[?1005l\x1B[?1015l\x1B[?1003l\x1B[?1002l\x1B[?1000l";
2
+
3
+ interface TerminalResetStdin {
4
+ isTTY?: boolean;
5
+ setRawMode?: (mode: boolean) => void;
6
+ }
7
+
8
+ interface TerminalResetStdout {
9
+ isTTY?: boolean;
10
+ write: (chunk: string) => unknown;
11
+ }
12
+
13
+ export function resetTerminalInputState(
14
+ stdin: TerminalResetStdin = process.stdin,
15
+ stdout: TerminalResetStdout = process.stdout,
16
+ ): void {
17
+ if (stdout.isTTY) {
18
+ stdout.write(TERMINAL_MOUSE_RESET_SEQUENCE);
19
+ }
20
+
21
+ if (stdin.isTTY !== false && typeof stdin.setRawMode === "function") {
22
+ stdin.setRawMode(false);
23
+ }
24
+ }
@@ -0,0 +1,145 @@
1
+ import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test";
2
+ import { createThrottledFetch } from "./throttled-fetch";
3
+
4
+ // Mock global fetch
5
+ const originalFetch = globalThis.fetch;
6
+ let fetchMock: ReturnType<typeof mock>;
7
+
8
+ beforeEach(() => {
9
+ fetchMock = mock(() => Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })));
10
+ globalThis.fetch = fetchMock as any;
11
+ });
12
+
13
+ describe("createThrottledFetch", () => {
14
+ test("passes through a simple request", async () => {
15
+ const client = createThrottledFetch();
16
+ const resp = await client.fetch("https://api.example.com/test");
17
+ expect(resp.status).toBe(200);
18
+ expect(fetchMock).toHaveBeenCalledTimes(1);
19
+ });
20
+
21
+ test("merges default headers", async () => {
22
+ const client = createThrottledFetch({
23
+ defaultHeaders: { "X-Custom": "value" },
24
+ });
25
+ await client.fetch("https://api.example.com/test");
26
+ const callInit = fetchMock.mock.calls[0]![1] as RequestInit;
27
+ expect((callInit.headers as Record<string, string>)["X-Custom"]).toBe("value");
28
+ });
29
+
30
+ test("deduplicates concurrent GET requests to same URL", async () => {
31
+ let resolveFirst: (r: Response) => void;
32
+ const slowResponse = new Promise<Response>((resolve) => { resolveFirst = resolve; });
33
+ fetchMock = mock(() => slowResponse);
34
+ globalThis.fetch = fetchMock as any;
35
+
36
+ const client = createThrottledFetch();
37
+ const p1 = client.fetch("https://api.example.com/same");
38
+ const p2 = client.fetch("https://api.example.com/same");
39
+
40
+ resolveFirst!(new Response("ok", { status: 200 }));
41
+ const [r1, r2] = await Promise.all([p1, p2]);
42
+ expect(r1).not.toBe(r2);
43
+ expect(await r1.text()).toBe("ok");
44
+ expect(await r2.text()).toBe("ok");
45
+ expect(fetchMock).toHaveBeenCalledTimes(1);
46
+ });
47
+
48
+ test("does not deduplicate different URLs", async () => {
49
+ const client = createThrottledFetch();
50
+ await Promise.all([
51
+ client.fetch("https://api.example.com/a"),
52
+ client.fetch("https://api.example.com/b"),
53
+ ]);
54
+ expect(fetchMock).toHaveBeenCalledTimes(2);
55
+ });
56
+
57
+ test("retries on 429 with backoff", async () => {
58
+ let callCount = 0;
59
+ fetchMock = mock(() => {
60
+ callCount++;
61
+ if (callCount === 1) {
62
+ return Promise.resolve(new Response("rate limited", { status: 429 }));
63
+ }
64
+ return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
65
+ });
66
+ globalThis.fetch = fetchMock as any;
67
+
68
+ const client = createThrottledFetch({ maxRetries: 1, backoffBaseMs: 0 });
69
+ const resp = await client.fetch("https://api.example.com/test");
70
+ expect(resp.status).toBe(200);
71
+ expect(fetchMock).toHaveBeenCalledTimes(2);
72
+ });
73
+
74
+ test("retries on 500 with backoff", async () => {
75
+ let callCount = 0;
76
+ fetchMock = mock(() => {
77
+ callCount++;
78
+ if (callCount === 1) {
79
+ return Promise.resolve(new Response("error", { status: 500 }));
80
+ }
81
+ return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
82
+ });
83
+ globalThis.fetch = fetchMock as any;
84
+
85
+ const client = createThrottledFetch({ maxRetries: 1, backoffBaseMs: 0 });
86
+ const resp = await client.fetch("https://api.example.com/test");
87
+ expect(resp.status).toBe(200);
88
+ expect(fetchMock).toHaveBeenCalledTimes(2);
89
+ });
90
+
91
+ test("retries transient fetch failures", async () => {
92
+ let callCount = 0;
93
+ fetchMock = mock(() => {
94
+ callCount++;
95
+ if (callCount === 1) {
96
+ return Promise.reject(
97
+ Object.assign(new Error("The socket connection was closed unexpectedly."), {
98
+ code: "ECONNRESET",
99
+ }),
100
+ );
101
+ }
102
+ return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
103
+ });
104
+ globalThis.fetch = fetchMock as any;
105
+
106
+ const client = createThrottledFetch({ maxRetries: 1, backoffBaseMs: 0 });
107
+ const resp = await client.fetch("https://api.example.com/test");
108
+ expect(resp.status).toBe(200);
109
+ expect(fetchMock).toHaveBeenCalledTimes(2);
110
+ });
111
+
112
+ test("stops retrying after max retries", async () => {
113
+ fetchMock = mock(() => Promise.resolve(new Response("error", { status: 429 })));
114
+ globalThis.fetch = fetchMock as any;
115
+
116
+ const client = createThrottledFetch({ maxRetries: 1, backoffBaseMs: 0 });
117
+ const resp = await client.fetch("https://api.example.com/test");
118
+ expect(resp.status).toBe(429);
119
+ expect(fetchMock).toHaveBeenCalledTimes(2); // initial + 1 retry
120
+ });
121
+
122
+ test("fetchJson parses response and throws on error", async () => {
123
+ const client = createThrottledFetch();
124
+ const data = await client.fetchJson<{ ok: boolean }>("https://api.example.com/test");
125
+ expect(data).toEqual({ ok: true });
126
+
127
+ fetchMock = mock(() => Promise.resolve(new Response("not found", { status: 404 })));
128
+ globalThis.fetch = fetchMock as any;
129
+ const client2 = createThrottledFetch({ maxRetries: 0 });
130
+ await expect(client2.fetchJson("https://api.example.com/test")).rejects.toThrow("HTTP 404");
131
+ });
132
+
133
+ test("fetchJson throws friendly message on 429", async () => {
134
+ fetchMock = mock(() => Promise.resolve(new Response("", { status: 429 })));
135
+ globalThis.fetch = fetchMock as any;
136
+
137
+ const client = createThrottledFetch({ maxRetries: 0 });
138
+ await expect(client.fetchJson("https://api.example.com/test")).rejects.toThrow("Rate limited");
139
+ });
140
+ });
141
+
142
+ // Restore
143
+ afterAll(() => {
144
+ globalThis.fetch = originalFetch;
145
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Rate-limited fetch with per-host throttling, retry with backoff, and request deduplication.
3
+ *
4
+ * Usage:
5
+ * const client = createThrottledFetch({ requestsPerMinute: 30 });
6
+ * const data = await client.fetch("https://api.example.com/data");
7
+ */
8
+
9
+ const DEFAULT_REQUESTS_PER_MINUTE = 30;
10
+ const DEFAULT_MAX_RETRIES = 2;
11
+ const DEFAULT_TIMEOUT_MS = 10_000;
12
+ const BACKOFF_BASE_MS = 1_000;
13
+
14
+ export interface ThrottledFetchOptions {
15
+ /** Max requests per minute per host. Default: 30 */
16
+ requestsPerMinute?: number;
17
+ /** Max retries on 429 or 5xx. Default: 2 */
18
+ maxRetries?: number;
19
+ /** Request timeout in ms. Default: 10000 */
20
+ timeoutMs?: number;
21
+ /** Initial retry backoff in ms. Default: 1000 */
22
+ backoffBaseMs?: number;
23
+ /** Default headers applied to every request */
24
+ defaultHeaders?: Record<string, string>;
25
+ /** Share concurrent GET requests to the same URL. Default: true */
26
+ dedupeGetRequests?: boolean;
27
+ }
28
+
29
+ interface QueueEntry {
30
+ resolve: (value: Response) => void;
31
+ reject: (reason: unknown) => void;
32
+ url: string;
33
+ init: RequestInit | undefined;
34
+ retries: number;
35
+ }
36
+
37
+ export interface ThrottledFetchClient {
38
+ fetch(url: string, init?: RequestInit): Promise<Response>;
39
+ fetchJson<T = unknown>(url: string, init?: RequestInit): Promise<T>;
40
+ }
41
+
42
+ export function createThrottledFetch(
43
+ options: ThrottledFetchOptions = {},
44
+ ): ThrottledFetchClient {
45
+ const requestsPerMinute =
46
+ options.requestsPerMinute ?? DEFAULT_REQUESTS_PER_MINUTE;
47
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
48
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
49
+ const backoffBaseMs = options.backoffBaseMs ?? BACKOFF_BASE_MS;
50
+ const defaultHeaders = options.defaultHeaders ?? {};
51
+ const dedupeGetRequests = options.dedupeGetRequests ?? true;
52
+
53
+ // Sliding window: track timestamps of recent requests per host
54
+ const hostTimestamps = new Map<string, number[]>();
55
+
56
+ // Deduplication: in-flight requests by URL
57
+ const inflight = new Map<string, Promise<Response>>();
58
+
59
+ function getHost(url: string): string {
60
+ try {
61
+ return new URL(url).host;
62
+ } catch {
63
+ return "unknown";
64
+ }
65
+ }
66
+
67
+ function getDelayMs(host: string): number {
68
+ const timestamps = hostTimestamps.get(host);
69
+ if (!timestamps || timestamps.length === 0) return 0;
70
+
71
+ const windowStart = Date.now() - 60_000;
72
+ // Prune old entries
73
+ while (timestamps.length > 0 && timestamps[0]! < windowStart) {
74
+ timestamps.shift();
75
+ }
76
+
77
+ if (timestamps.length < requestsPerMinute) return 0;
78
+
79
+ // Delay until the oldest request in the window falls outside
80
+ const oldest = timestamps[0]!;
81
+ return Math.max(0, oldest + 60_000 - Date.now() + 50); // +50ms buffer
82
+ }
83
+
84
+ function recordRequest(host: string): void {
85
+ let timestamps = hostTimestamps.get(host);
86
+ if (!timestamps) {
87
+ timestamps = [];
88
+ hostTimestamps.set(host, timestamps);
89
+ }
90
+ timestamps.push(Date.now());
91
+ }
92
+
93
+ function getBackoffMs(retriesLeft: number, retryAfter?: string | null): number {
94
+ const attempt = maxRetries - retriesLeft + 1;
95
+ const backoff = backoffBaseMs * 2 ** (attempt - 1);
96
+ const retryAfterMs = retryAfter
97
+ ? (parseInt(retryAfter, 10) || 0) * 1000
98
+ : 0;
99
+ return Math.max(retryAfterMs, backoff);
100
+ }
101
+
102
+ function isRetryableFetchError(
103
+ error: unknown,
104
+ retryAbortError: boolean,
105
+ ): boolean {
106
+ const detail =
107
+ error instanceof Error
108
+ ? `${error.name} ${error.message} ${String((error as { code?: unknown }).code ?? "")}`
109
+ : String(error);
110
+ return (
111
+ /TimeoutError|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket connection|socket hang up|fetch failed|network connection|connection closed/i.test(
112
+ detail,
113
+ ) ||
114
+ (retryAbortError && /AbortError/i.test(detail))
115
+ );
116
+ }
117
+
118
+ async function executeRequest(
119
+ url: string,
120
+ init: RequestInit | undefined,
121
+ retriesLeft: number,
122
+ ): Promise<Response> {
123
+ const host = getHost(url);
124
+
125
+ // Wait for rate limit window
126
+ const delay = getDelayMs(host);
127
+ if (delay > 0) {
128
+ await new Promise((resolve) => setTimeout(resolve, delay));
129
+ }
130
+
131
+ recordRequest(host);
132
+
133
+ const mergedInit: RequestInit = {
134
+ ...init,
135
+ headers: {
136
+ ...defaultHeaders,
137
+ ...((init?.headers as Record<string, string>) ?? {}),
138
+ },
139
+ signal: init?.signal ?? AbortSignal.timeout(timeoutMs),
140
+ };
141
+
142
+ let resp: Response;
143
+ try {
144
+ resp = await fetch(url, mergedInit);
145
+ } catch (error) {
146
+ if (retriesLeft > 0 && isRetryableFetchError(error, !init?.signal)) {
147
+ await new Promise((resolve) =>
148
+ setTimeout(resolve, getBackoffMs(retriesLeft)),
149
+ );
150
+ return executeRequest(url, init, retriesLeft - 1);
151
+ }
152
+ throw error;
153
+ }
154
+
155
+ // Retry on 429 or 5xx
156
+ if ((resp.status === 429 || resp.status >= 500) && retriesLeft > 0) {
157
+ await new Promise((resolve) =>
158
+ setTimeout(
159
+ resolve,
160
+ getBackoffMs(retriesLeft, resp.headers.get("retry-after")),
161
+ ),
162
+ );
163
+ return executeRequest(url, init, retriesLeft - 1);
164
+ }
165
+
166
+ return resp;
167
+ }
168
+
169
+ function throttledFetch(url: string, init?: RequestInit): Promise<Response> {
170
+ // Deduplicate concurrent GET requests to the same URL
171
+ const method = (init?.method ?? "GET").toUpperCase();
172
+ if (dedupeGetRequests && method === "GET") {
173
+ const existing = inflight.get(url);
174
+ if (existing) {
175
+ return existing.then(
176
+ (response): Response => response.clone() as unknown as Response,
177
+ );
178
+ }
179
+ }
180
+
181
+ const promise = executeRequest(url, init, maxRetries).finally(() => {
182
+ inflight.delete(url);
183
+ });
184
+
185
+ if (dedupeGetRequests && method === "GET") {
186
+ inflight.set(url, promise);
187
+ }
188
+
189
+ return dedupeGetRequests && method === "GET"
190
+ ? promise.then(
191
+ (response): Response => response.clone() as unknown as Response,
192
+ )
193
+ : promise;
194
+ }
195
+
196
+ async function fetchJson<T = unknown>(
197
+ url: string,
198
+ init?: RequestInit,
199
+ ): Promise<T> {
200
+ const resp = await throttledFetch(url, init);
201
+ if (!resp.ok) {
202
+ throw new Error(
203
+ resp.status === 429
204
+ ? `Rate limited (${getHost(url)}) — try again later`
205
+ : `HTTP ${resp.status} from ${getHost(url)}`,
206
+ );
207
+ }
208
+ return resp.json() as Promise<T>;
209
+ }
210
+
211
+ return { fetch: throttledFetch, fetchJson };
212
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { collectUniqueTickerSymbols, tokenizeTickerText } from "./ticker-tokenizer";
3
+
4
+ describe("tokenizeTickerText", () => {
5
+ test("finds normal inline ticker symbols", () => {
6
+ expect(tokenizeTickerText("Watching $TSLA and $NVDA")).toEqual([
7
+ { kind: "text", value: "Watching " },
8
+ { kind: "ticker", value: "$TSLA", symbol: "TSLA" },
9
+ { kind: "text", value: " and " },
10
+ { kind: "ticker", value: "$NVDA", symbol: "NVDA" },
11
+ ]);
12
+ });
13
+
14
+ test("does not tokenize ticker-like text inside links", () => {
15
+ expect(tokenizeTickerText("Read https://example.com/$TSLA before buying $NVDA")).toEqual([
16
+ { kind: "text", value: "Read " },
17
+ { kind: "text", value: "https://example.com/$TSLA" },
18
+ { kind: "text", value: " before buying " },
19
+ { kind: "ticker", value: "$NVDA", symbol: "NVDA" },
20
+ ]);
21
+ });
22
+ });
23
+
24
+ describe("collectUniqueTickerSymbols", () => {
25
+ test("ignores ticker-like text inside links", () => {
26
+ expect(collectUniqueTickerSymbols([
27
+ "Read https://example.com/$TSLA",
28
+ "Watching $NVDA",
29
+ ])).toEqual(["NVDA"]);
30
+ });
31
+ });
@@ -1,3 +1,5 @@
1
+ import { tokenizeInlineContent } from "./inline-content-tokenizer";
2
+
1
3
  export interface InlineTickerTextToken {
2
4
  kind: "text";
3
5
  value: string;
@@ -11,49 +13,12 @@ export interface InlineTickerSymbolToken {
11
13
 
12
14
  export type InlineTickerToken = InlineTickerTextToken | InlineTickerSymbolToken;
13
15
 
14
- const TICKER_TOKEN_RE = /\$[A-Z][A-Z0-9.-]{0,9}/g;
15
- const SYMBOL_CHAR_RE = /[A-Za-z0-9.-]/;
16
-
17
- function trimTrailingPunctuation(value: string): string {
18
- let trimmed = value;
19
- while (trimmed.length > 2 && /[.-]$/.test(trimmed)) {
20
- trimmed = trimmed.slice(0, -1);
21
- }
22
- return trimmed;
23
- }
24
-
25
- function isValidBoundary(text: string, start: number, end: number): boolean {
26
- const prev = start > 0 ? text[start - 1] : "";
27
- const next = end < text.length ? text[end] : "";
28
- return !SYMBOL_CHAR_RE.test(prev) && !SYMBOL_CHAR_RE.test(next);
29
- }
30
-
31
16
  export function tokenizeTickerText(text: string): InlineTickerToken[] {
32
- const tokens: InlineTickerToken[] = [];
33
- let cursor = 0;
34
- let match: RegExpExecArray | null;
35
-
36
- TICKER_TOKEN_RE.lastIndex = 0;
37
- while ((match = TICKER_TOKEN_RE.exec(text)) !== null) {
38
- const rawValue = match[0];
39
- const start = match.index;
40
- const rawEnd = start + rawValue.length;
41
- const value = trimTrailingPunctuation(rawValue);
42
- const end = start + value.length;
43
- if (!isValidBoundary(text, start, rawEnd)) continue;
44
-
45
- if (start > cursor) {
46
- tokens.push({ kind: "text", value: text.slice(cursor, start) });
47
- }
48
- tokens.push({ kind: "ticker", value, symbol: value.slice(1) });
49
- cursor = end;
50
- }
51
-
52
- if (cursor < text.length) {
53
- tokens.push({ kind: "text", value: text.slice(cursor) });
54
- }
55
-
56
- return tokens;
17
+ return tokenizeInlineContent(text).map((token) => (
18
+ token.kind === "ticker"
19
+ ? token
20
+ : { kind: "text", value: token.value }
21
+ ));
57
22
  }
58
23
 
59
24
  export function collectUniqueTickerSymbols(texts: readonly string[]): string[] {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.4.1";
1
+ export const VERSION = "0.5.0";