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
@@ -2,13 +2,33 @@ import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { useKeyboard } from "@opentui/react";
3
3
  import { TextAttributes } from "@opentui/core";
4
4
  import type { GloomPlugin, PaneProps } from "../../types/plugin";
5
- import { useAppState } from "../../state/app-context";
6
5
  import { colors, hoverBg } from "../../theme/colors";
7
6
  import { debugLog, type LogEntry, type LogLevel } from "../../utils/debug-log";
8
7
  import { writeFileSync } from "fs";
9
8
  import { join } from "path";
10
9
  import { homedir } from "os";
11
10
 
11
+ function exportDebugLogFile(options: {
12
+ filterLevel?: LogLevel | null;
13
+ filterSource?: string | null;
14
+ }): { ok: true; filename: string } | { ok: false } {
15
+ const text = debugLog.exportAsText(
16
+ options.filterLevel || options.filterSource
17
+ ? { level: options.filterLevel ?? undefined, source: options.filterSource ?? undefined }
18
+ : undefined,
19
+ );
20
+ const downloadsDir = join(homedir(), "Downloads");
21
+ const filename = `gloomberb-debug-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.log`;
22
+ const filepath = join(downloadsDir, filename);
23
+
24
+ try {
25
+ writeFileSync(filepath, text);
26
+ return { ok: true, filename };
27
+ } catch {
28
+ return { ok: false };
29
+ }
30
+ }
31
+
12
32
  function levelColor(level: LogLevel): string {
13
33
  switch (level) {
14
34
  case "debug": return colors.textDim;
@@ -32,8 +52,7 @@ function formatTimestamp(ts: number): string {
32
52
  return d.toISOString().slice(11, 23);
33
53
  }
34
54
 
35
- function DebugPane({ focused, width, height, close }: PaneProps) {
36
- const { dispatch } = useAppState();
55
+ function DebugPane({ focused, width, height }: PaneProps) {
37
56
  const [entries, setEntries] = useState<LogEntry[]>(() => debugLog.getEntries());
38
57
  const [filterLevel, setFilterLevel] = useState<LogLevel | null>(null);
39
58
  const [filterSource, setFilterSource] = useState<string | null>(null);
@@ -59,22 +78,13 @@ function DebugPane({ focused, width, height, close }: PaneProps) {
59
78
  }, [filterLevel, filterSource, autoScroll]);
60
79
 
61
80
  const exportLogs = useCallback(() => {
62
- const text = debugLog.exportAsText(
63
- filterLevel || filterSource
64
- ? { level: filterLevel ?? undefined, source: filterSource ?? undefined }
65
- : undefined,
66
- );
67
- const downloadsDir = join(homedir(), "Downloads");
68
- const filename = `gloomberb-debug-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.log`;
69
- const filepath = join(downloadsDir, filename);
70
- try {
71
- writeFileSync(filepath, text);
72
- const registry = (globalThis as any).__gloomRegistry;
73
- registry?.showToastFn?.(`Exported to ~/Downloads/${filename}`, { type: "success" });
74
- } catch {
75
- const registry = (globalThis as any).__gloomRegistry;
76
- registry?.showToastFn?.("Failed to export logs", { type: "error" });
81
+ const result = exportDebugLogFile({ filterLevel, filterSource });
82
+ const registry = (globalThis as any).__gloomRegistry;
83
+ if (result.ok) {
84
+ registry?.notify?.({ body: `Exported to ~/Downloads/${result.filename}`, type: "success" });
85
+ return;
77
86
  }
87
+ registry?.notify?.({ body: "Failed to export logs", type: "error" });
78
88
  }, [filterLevel, filterSource]);
79
89
 
80
90
  const clearLogs = useCallback(() => {
@@ -150,20 +160,15 @@ function DebugPane({ focused, width, height, close }: PaneProps) {
150
160
  return;
151
161
  }
152
162
 
153
- if (event.name === "escape") {
154
- if (showDetail) {
155
- setShowDetail(false);
156
- } else {
157
- close?.();
158
- }
163
+ if (event.name === "escape" && showDetail) {
164
+ setShowDetail(false);
159
165
  return;
160
166
  }
161
167
  });
162
168
 
163
169
  const headerHeight = 2;
164
- const footerHeight = 1;
165
170
  const contentWidth = Math.max(1, width - 2);
166
- const messageAreaHeight = Math.max(1, height - headerHeight - footerHeight);
171
+ const messageAreaHeight = Math.max(1, height - headerHeight);
167
172
 
168
173
  // Compute visible window
169
174
  const visibleCount = showDetail ? Math.max(1, messageAreaHeight - 4) : messageAreaHeight;
@@ -279,21 +284,6 @@ function DebugPane({ focused, width, height, close }: PaneProps) {
279
284
  </box>
280
285
  </box>
281
286
  )}
282
-
283
- {/* Footer */}
284
- <box height={1} width={contentWidth} flexDirection="row" paddingLeft={1}>
285
- <text fg={colors.textMuted}>
286
- <span fg={colors.text}>l</span>evel
287
- {" "}
288
- <span fg={colors.text}>s</span>ource
289
- {" "}
290
- <span fg={colors.text}>e</span>xport
291
- {" "}
292
- <span fg={colors.text}>x</span> clear
293
- {" "}
294
- <span fg={colors.text}>a</span>uto-scroll
295
- </text>
296
- </box>
297
287
  </box>
298
288
  );
299
289
  }
@@ -334,15 +324,11 @@ export const debugPlugin: GloomPlugin = {
334
324
  keywords: ["export", "debug", "log", "download", "save"],
335
325
  category: "config",
336
326
  execute: () => {
337
- const text = debugLog.exportAsText();
338
- const downloadsDir = join(homedir(), "Downloads");
339
- const filename = `gloomberb-debug-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.log`;
340
- const filepath = join(downloadsDir, filename);
341
- try {
342
- writeFileSync(filepath, text);
343
- ctx.showToast(`Exported to ~/Downloads/${filename}`, { type: "success" });
344
- } catch {
345
- ctx.showToast("Failed to export logs", { type: "error" });
327
+ const result = exportDebugLogFile({});
328
+ if (result.ok) {
329
+ ctx.notify({ body: `Exported to ~/Downloads/${result.filename}`, type: "success" });
330
+ } else {
331
+ ctx.notify({ body: "Failed to export logs", type: "error" });
346
332
  }
347
333
  },
348
334
  });
@@ -0,0 +1,181 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { DataProvider, EarningsEvent } from "../../../types/data-provider";
3
+ import type { PersistedResourceValue } from "../../../types/persistence";
4
+ import type { PluginPersistence } from "../../../types/plugin";
5
+ import {
6
+ attachEarningsCalendarPersistence,
7
+ buildEarningsCacheKey,
8
+ EARNINGS_CALENDAR_CACHE_POLICY,
9
+ loadEarningsCalendar,
10
+ resetEarningsCalendarPersistence,
11
+ } from "./earnings-cache";
12
+
13
+ class MemoryPluginPersistence implements PluginPersistence {
14
+ private readonly resources = new Map<string, PersistedResourceValue<unknown>>();
15
+ private readonly state = new Map<string, { schemaVersion: number; value: unknown }>();
16
+
17
+ getState<T = unknown>(key: string, options?: { schemaVersion?: number }): T | null {
18
+ const record = this.state.get(key);
19
+ if (!record) return null;
20
+ if (options?.schemaVersion != null && record.schemaVersion !== options.schemaVersion) return null;
21
+ return record.value as T;
22
+ }
23
+
24
+ setState(key: string, value: unknown, options?: { schemaVersion?: number }): void {
25
+ this.state.set(key, { schemaVersion: options?.schemaVersion ?? 1, value });
26
+ }
27
+
28
+ deleteState(key: string): void {
29
+ this.state.delete(key);
30
+ }
31
+
32
+ getResource<T = unknown>(
33
+ kind: string,
34
+ key: string,
35
+ options?: { sourceKey?: string; schemaVersion?: number; allowExpired?: boolean },
36
+ ): PersistedResourceValue<T> | null {
37
+ const record = this.resources.get(this.resourceKey(kind, key, options?.sourceKey));
38
+ if (!record) return null;
39
+ if (options?.schemaVersion != null && record.schemaVersion !== options.schemaVersion) return null;
40
+ const next = this.withFreshness(record);
41
+ if (!options?.allowExpired && next.expired) return null;
42
+ return next as PersistedResourceValue<T>;
43
+ }
44
+
45
+ setResource<T = unknown>(
46
+ kind: string,
47
+ key: string,
48
+ value: T,
49
+ options: {
50
+ cachePolicy: { staleMs: number; expireMs: number };
51
+ sourceKey?: string;
52
+ schemaVersion?: number;
53
+ provenance?: unknown;
54
+ },
55
+ ): PersistedResourceValue<T> {
56
+ const now = Date.now();
57
+ const record: PersistedResourceValue<T> = {
58
+ value,
59
+ fetchedAt: now,
60
+ staleAt: now + options.cachePolicy.staleMs,
61
+ expiresAt: now + options.cachePolicy.expireMs,
62
+ sourceKey: options.sourceKey ?? "",
63
+ schemaVersion: options.schemaVersion ?? 1,
64
+ provenance: options.provenance,
65
+ stale: false,
66
+ expired: false,
67
+ };
68
+ this.resources.set(this.resourceKey(kind, key, options.sourceKey), record);
69
+ return record;
70
+ }
71
+
72
+ deleteResource(kind: string, key: string, options?: { sourceKey?: string }): void {
73
+ this.resources.delete(this.resourceKey(kind, key, options?.sourceKey));
74
+ }
75
+
76
+ seedResource<T>(
77
+ kind: string,
78
+ key: string,
79
+ value: T,
80
+ options: { sourceKey?: string; stale?: boolean; expired?: boolean } = {},
81
+ ): void {
82
+ const now = Date.now();
83
+ const record: PersistedResourceValue<T> = {
84
+ value,
85
+ fetchedAt: now - 60_000,
86
+ staleAt: options.stale ? now - 1 : now + EARNINGS_CALENDAR_CACHE_POLICY.staleMs,
87
+ expiresAt: options.expired ? now - 1 : now + EARNINGS_CALENDAR_CACHE_POLICY.expireMs,
88
+ sourceKey: options.sourceKey ?? "",
89
+ schemaVersion: 1,
90
+ stale: !!options.stale,
91
+ expired: !!options.expired,
92
+ };
93
+ this.resources.set(this.resourceKey(kind, key, options.sourceKey), record);
94
+ }
95
+
96
+ private resourceKey(kind: string, key: string, sourceKey = ""): string {
97
+ return `${kind}:${key}:${sourceKey}`;
98
+ }
99
+
100
+ private withFreshness<T>(record: PersistedResourceValue<T>): PersistedResourceValue<T> {
101
+ const now = Date.now();
102
+ return {
103
+ ...record,
104
+ stale: now >= record.staleAt,
105
+ expired: now >= record.expiresAt,
106
+ };
107
+ }
108
+ }
109
+
110
+ function eventFor(symbol: string): EarningsEvent {
111
+ return {
112
+ symbol,
113
+ name: `${symbol} Corp`,
114
+ earningsDate: new Date("2026-05-01T12:00:00Z"),
115
+ epsEstimate: 1.23,
116
+ epsActual: null,
117
+ revenueEstimate: 1_000_000,
118
+ revenueActual: null,
119
+ surprise: null,
120
+ timing: "",
121
+ };
122
+ }
123
+
124
+ afterEach(() => {
125
+ resetEarningsCalendarPersistence();
126
+ });
127
+
128
+ describe("buildEarningsCacheKey", () => {
129
+ test("normalizes symbol order, case, and duplicates", () => {
130
+ expect(buildEarningsCacheKey([" msft ", "AAPL", "aapl"])).toBe("AAPL,MSFT");
131
+ });
132
+ });
133
+
134
+ describe("loadEarningsCalendar", () => {
135
+ test("keeps separate caches for different symbol sets", async () => {
136
+ const calls: string[][] = [];
137
+ const provider = {
138
+ id: "test",
139
+ name: "Test",
140
+ getEarningsCalendar: async (symbols: string[]) => {
141
+ calls.push(symbols);
142
+ return symbols.map(eventFor);
143
+ },
144
+ } as DataProvider;
145
+
146
+ const aapl = await loadEarningsCalendar(provider, ["AAPL"]);
147
+ const msft = await loadEarningsCalendar(provider, ["MSFT"]);
148
+ const aaplAgain = await loadEarningsCalendar(provider, ["aapl"]);
149
+
150
+ expect(aapl.map((event) => event.symbol)).toEqual(["AAPL"]);
151
+ expect(msft.map((event) => event.symbol)).toEqual(["MSFT"]);
152
+ expect(aaplAgain.map((event) => event.symbol)).toEqual(["AAPL"]);
153
+ expect(calls).toEqual([["AAPL"], ["MSFT"]]);
154
+ });
155
+
156
+ test("falls back to stale persisted data when refresh fails", async () => {
157
+ const persistence = new MemoryPluginPersistence();
158
+ attachEarningsCalendarPersistence(persistence);
159
+ persistence.seedResource("calendar", "AAPL", [{
160
+ ...eventFor("AAPL"),
161
+ earningsDate: "2026-05-01T12:00:00.000Z",
162
+ }], {
163
+ sourceKey: "earnings",
164
+ stale: true,
165
+ });
166
+
167
+ const provider = {
168
+ id: "test",
169
+ name: "Test",
170
+ getEarningsCalendar: async () => {
171
+ throw new Error("offline");
172
+ },
173
+ } as DataProvider;
174
+
175
+ const events = await loadEarningsCalendar(provider, ["AAPL"], { force: true });
176
+
177
+ expect(events).toHaveLength(1);
178
+ expect(events[0]!.symbol).toBe("AAPL");
179
+ expect(events[0]!.earningsDate).toBeInstanceOf(Date);
180
+ });
181
+ });
@@ -0,0 +1,136 @@
1
+ import type { DataProvider, EarningsEvent } from "../../../types/data-provider";
2
+ import type { PersistedResourceValue } from "../../../types/persistence";
3
+ import type { PluginPersistence } from "../../../types/plugin";
4
+
5
+ const CACHE_KIND = "calendar";
6
+ const CACHE_SOURCE = "earnings";
7
+ const CACHE_SCHEMA_VERSION = 1;
8
+
9
+ export const EARNINGS_CALENDAR_CACHE_POLICY = {
10
+ staleMs: 30 * 60 * 1000,
11
+ expireMs: 7 * 24 * 60 * 60 * 1000,
12
+ } as const;
13
+
14
+ interface MemoryCacheEntry {
15
+ data: EarningsEvent[];
16
+ fetchedAt: number;
17
+ }
18
+
19
+ type PersistedEarningsEvent = Omit<EarningsEvent, "earningsDate"> & {
20
+ earningsDate: string;
21
+ };
22
+
23
+ let earningsPersistence: PluginPersistence | null = null;
24
+ const memoryCache = new Map<string, MemoryCacheEntry>();
25
+ const activeFetches = new Map<string, Promise<EarningsEvent[]>>();
26
+
27
+ export function attachEarningsCalendarPersistence(persistence: PluginPersistence): void {
28
+ earningsPersistence = persistence;
29
+ }
30
+
31
+ export function resetEarningsCalendarPersistence(): void {
32
+ earningsPersistence = null;
33
+ memoryCache.clear();
34
+ activeFetches.clear();
35
+ }
36
+
37
+ export function normalizeEarningsSymbols(symbols: string[]): string[] {
38
+ return Array.from(
39
+ new Set(
40
+ symbols
41
+ .map((symbol) => symbol.trim().toUpperCase())
42
+ .filter(Boolean),
43
+ ),
44
+ ).sort();
45
+ }
46
+
47
+ export function buildEarningsCacheKey(symbols: string[]): string {
48
+ const normalized = normalizeEarningsSymbols(symbols);
49
+ return normalized.length > 0 ? normalized.join(",") : "empty";
50
+ }
51
+
52
+ function serializeEvents(events: EarningsEvent[]): PersistedEarningsEvent[] {
53
+ return events.map((event) => ({
54
+ ...event,
55
+ earningsDate: event.earningsDate.toISOString(),
56
+ }));
57
+ }
58
+
59
+ function deserializeEvents(events: PersistedEarningsEvent[]): EarningsEvent[] {
60
+ return events
61
+ .map((event) => ({
62
+ ...event,
63
+ earningsDate: new Date(event.earningsDate),
64
+ }))
65
+ .filter((event) => !Number.isNaN(event.earningsDate.getTime()));
66
+ }
67
+
68
+ function readPersistedCache(
69
+ key: string,
70
+ options?: { allowExpired?: boolean },
71
+ ): PersistedResourceValue<PersistedEarningsEvent[]> | null {
72
+ return earningsPersistence?.getResource<PersistedEarningsEvent[]>(CACHE_KIND, key, {
73
+ sourceKey: CACHE_SOURCE,
74
+ schemaVersion: CACHE_SCHEMA_VERSION,
75
+ allowExpired: options?.allowExpired,
76
+ }) ?? null;
77
+ }
78
+
79
+ function writeCache(key: string, events: EarningsEvent[]): void {
80
+ memoryCache.set(key, { data: events, fetchedAt: Date.now() });
81
+ earningsPersistence?.setResource(CACHE_KIND, key, serializeEvents(events), {
82
+ sourceKey: CACHE_SOURCE,
83
+ schemaVersion: CACHE_SCHEMA_VERSION,
84
+ cachePolicy: EARNINGS_CALENDAR_CACHE_POLICY,
85
+ });
86
+ }
87
+
88
+ export async function loadEarningsCalendar(
89
+ provider: DataProvider | null | undefined,
90
+ symbols: string[],
91
+ options?: { force?: boolean },
92
+ ): Promise<EarningsEvent[]> {
93
+ const normalizedSymbols = normalizeEarningsSymbols(symbols);
94
+ if (normalizedSymbols.length === 0 || !provider?.getEarningsCalendar) return [];
95
+
96
+ const key = buildEarningsCacheKey(normalizedSymbols);
97
+ const force = options?.force ?? false;
98
+ const memoryEntry = memoryCache.get(key);
99
+ if (!force && memoryEntry && Date.now() - memoryEntry.fetchedAt < EARNINGS_CALENDAR_CACHE_POLICY.staleMs) {
100
+ return memoryEntry.data;
101
+ }
102
+
103
+ const freshPersisted = readPersistedCache(key);
104
+ if (!force && freshPersisted && !freshPersisted.stale) {
105
+ const data = deserializeEvents(freshPersisted.value);
106
+ memoryCache.set(key, { data, fetchedAt: freshPersisted.fetchedAt });
107
+ return data;
108
+ }
109
+
110
+ const activeFetch = activeFetches.get(key);
111
+ if (activeFetch && !force) return activeFetch;
112
+
113
+ const fetchPromise = provider.getEarningsCalendar(normalizedSymbols)
114
+ .then((data) => {
115
+ writeCache(key, data);
116
+ return data;
117
+ })
118
+ .catch((error) => {
119
+ const stalePersisted = freshPersisted ?? readPersistedCache(key, { allowExpired: true });
120
+ if (stalePersisted) {
121
+ const data = deserializeEvents(stalePersisted.value);
122
+ memoryCache.set(key, { data, fetchedAt: stalePersisted.fetchedAt });
123
+ return data;
124
+ }
125
+ if (memoryEntry) return memoryEntry.data;
126
+ throw error;
127
+ })
128
+ .finally(() => {
129
+ if (activeFetches.get(key) === fetchPromise) {
130
+ activeFetches.delete(key);
131
+ }
132
+ });
133
+
134
+ activeFetches.set(key, fetchPromise);
135
+ return fetchPromise;
136
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseEarningsModules, parseEarningsDate } from "./earnings-data";
3
+
4
+ describe("parseEarningsDate", () => {
5
+ test("parses Unix timestamp", () => {
6
+ const d = parseEarningsDate({ raw: 1714521600 });
7
+ expect(d).toBeInstanceOf(Date);
8
+ expect(d!.getFullYear()).toBeGreaterThanOrEqual(2024);
9
+ });
10
+
11
+ test("returns null for missing data", () => {
12
+ expect(parseEarningsDate(null)).toBeNull();
13
+ expect(parseEarningsDate({})).toBeNull();
14
+ });
15
+ });
16
+
17
+ describe("parseEarningsModules", () => {
18
+ test("extracts earnings date and estimates", () => {
19
+ const result = parseEarningsModules("AAPL", "Apple Inc.", {
20
+ calendarEvents: {
21
+ earnings: {
22
+ earningsDate: [{ raw: 1714521600 }],
23
+ earningsAverage: { raw: 1.53 },
24
+ revenueAverage: { raw: 90500000000 },
25
+ },
26
+ },
27
+ earningsTrend: { trend: [] },
28
+ });
29
+ expect(result).not.toBeNull();
30
+ expect(result!.symbol).toBe("AAPL");
31
+ expect(result!.epsEstimate).toBe(1.53);
32
+ expect(result!.earningsDate).toBeInstanceOf(Date);
33
+ });
34
+
35
+ test("prefers earningsTrend over calendarEvents", () => {
36
+ const result = parseEarningsModules("AAPL", "Apple Inc.", {
37
+ calendarEvents: {
38
+ earnings: {
39
+ earningsDate: [{ raw: 1714521600 }],
40
+ earningsAverage: { raw: 1.50 },
41
+ },
42
+ },
43
+ earningsTrend: {
44
+ trend: [{
45
+ period: "0q",
46
+ earningsEstimate: { avg: { raw: 1.55 } },
47
+ revenueEstimate: { avg: { raw: 91000000000 } },
48
+ }],
49
+ },
50
+ });
51
+ expect(result!.epsEstimate).toBe(1.55);
52
+ expect(result!.revenueEstimate).toBe(91000000000);
53
+ });
54
+
55
+ test("returns null when no earnings data", () => {
56
+ expect(parseEarningsModules("XYZ", "XYZ", {})).toBeNull();
57
+ expect(parseEarningsModules("XYZ", "XYZ", { calendarEvents: {} })).toBeNull();
58
+ });
59
+ });
@@ -0,0 +1,50 @@
1
+ export function parseEarningsDate(raw: any): Date | null {
2
+ if (!raw?.raw) return null;
3
+ return new Date(raw.raw * 1000);
4
+ }
5
+
6
+ export interface RawEarningsModules {
7
+ calendarEvents?: {
8
+ earnings?: {
9
+ earningsDate?: Array<{ raw: number }>;
10
+ earningsAverage?: { raw: number };
11
+ revenueAverage?: { raw: number };
12
+ };
13
+ };
14
+ earningsTrend?: {
15
+ trend?: Array<{
16
+ period: string;
17
+ earningsEstimate?: { avg?: { raw: number } };
18
+ revenueEstimate?: { avg?: { raw: number } };
19
+ }>;
20
+ };
21
+ }
22
+
23
+ export function parseEarningsModules(
24
+ symbol: string,
25
+ name: string,
26
+ modules: RawEarningsModules,
27
+ ): import("../../../types/data-provider").EarningsEvent | null {
28
+ const cal = modules?.calendarEvents;
29
+ if (!cal?.earnings) return null;
30
+
31
+ const dates = cal.earnings.earningsDate;
32
+ if (!Array.isArray(dates) || dates.length === 0) return null;
33
+
34
+ const earningsDate = parseEarningsDate(dates[0]);
35
+ if (!earningsDate) return null;
36
+
37
+ const currentQtr = modules?.earningsTrend?.trend?.find((t) => t.period === "0q");
38
+
39
+ return {
40
+ symbol,
41
+ name,
42
+ earningsDate,
43
+ epsEstimate: currentQtr?.earningsEstimate?.avg?.raw ?? cal.earnings.earningsAverage?.raw ?? null,
44
+ epsActual: null,
45
+ revenueEstimate: currentQtr?.revenueEstimate?.avg?.raw ?? cal.earnings.revenueAverage?.raw ?? null,
46
+ revenueActual: null,
47
+ surprise: null,
48
+ timing: "",
49
+ };
50
+ }