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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/app.tsx +168 -65
- package/src/cli/commands/ticker.ts +45 -15
- package/src/cli/helpers.ts +4 -2
- package/src/cli/registry.test.ts +42 -35
- package/src/cli.test.ts +4 -2
- package/src/components/chart/chart-control-hint.tsx +39 -0
- package/src/components/chart/chart-controller.test.ts +110 -0
- package/src/components/chart/chart-controller.ts +364 -2
- package/src/components/chart/chart-data.ts +177 -29
- package/src/components/chart/chart-indicator-selector.tsx +53 -0
- package/src/components/chart/chart-pane-settings.ts +20 -0
- package/src/components/chart/chart-renderer.ts +220 -30
- package/src/components/chart/chart-resolution.test.ts +67 -0
- package/src/components/chart/chart-resolution.ts +74 -4
- package/src/components/chart/chart-scroll.test.ts +69 -0
- package/src/components/chart/chart-scroll.ts +104 -0
- package/src/components/chart/chart-types.ts +12 -0
- package/src/components/chart/chart-viewport.test.ts +5 -1
- package/src/components/chart/chart-viewport.ts +1 -1
- package/src/components/chart/chart.test.ts +249 -10
- package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
- package/src/components/chart/comparison-chart-renderer.ts +40 -3
- package/src/components/chart/comparison-stock-chart.tsx +218 -123
- package/src/components/chart/indicators/bands.test.ts +50 -0
- package/src/components/chart/indicators/bands.ts +29 -0
- package/src/components/chart/indicators/moving-averages.test.ts +54 -0
- package/src/components/chart/indicators/moving-averages.ts +42 -0
- package/src/components/chart/indicators/options.test.ts +32 -0
- package/src/components/chart/indicators/options.ts +107 -0
- package/src/components/chart/indicators/oscillators.test.ts +79 -0
- package/src/components/chart/indicators/oscillators.ts +92 -0
- package/src/components/chart/indicators/types.ts +36 -0
- package/src/components/chart/native/chart-rasterizer.ts +51 -0
- package/src/components/chart/native/kitty.test.ts +68 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
- package/src/components/chart/stock-chart.test.ts +239 -0
- package/src/components/chart/stock-chart.tsx +2257 -372
- package/src/components/command-bar/command-bar.test.tsx +337 -5
- package/src/components/command-bar/command-bar.tsx +292 -120
- package/src/components/command-bar/helpers.ts +1 -1
- package/src/components/command-bar/root-shortcuts.ts +33 -4
- package/src/components/command-bar/view-model.test.ts +3 -3
- package/src/components/command-bar/view-model.ts +4 -4
- package/src/components/command-bar/workflow-ops.ts +10 -0
- package/src/components/layout/floating-pane.tsx +3 -2
- package/src/components/layout/header.tsx +2 -1
- package/src/components/layout/pane-sizing.ts +10 -0
- package/src/components/layout/pane.tsx +9 -2
- package/src/components/layout/shell.test.tsx +282 -28
- package/src/components/layout/shell.tsx +10 -7
- package/src/components/layout/status-bar.test.tsx +16 -7
- package/src/components/layout/status-bar.tsx +4 -1
- package/src/components/markdown-editor.tsx +1 -0
- package/src/components/pane-settings-dialog.tsx +13 -152
- package/src/components/pane-template-wizard.tsx +1 -0
- package/src/components/price-selector-dialog.tsx +12 -7
- package/src/components/ticker-badge-text.test.tsx +50 -0
- package/src/components/ticker-badge-text.tsx +17 -2
- package/src/components/ticker-badge.tsx +2 -2
- package/src/components/ticker-list-table.tsx +54 -1
- package/src/components/toggle-list.tsx +15 -2
- package/src/components/ui/data-table.tsx +60 -3
- package/src/components/ui/external-link.tsx +48 -11
- package/src/components/ui/fields.tsx +2 -1
- package/src/components/ui/index.ts +8 -1
- package/src/components/ui/multi-select-chips.tsx +69 -0
- package/src/components/ui/multi-select-dialog.tsx +267 -0
- package/src/components/ui/multi-select.ts +78 -0
- package/src/components/ui/ui.test.tsx +343 -1
- package/src/data/plugin-state-store.ts +30 -19
- package/src/data/resource-store.ts +133 -103
- package/src/data/session-store.ts +19 -12
- package/src/data/sqlite-cache.test.ts +37 -2
- package/src/data/sqlite-database.ts +8 -2
- package/src/data/sqlite-retry.test.ts +63 -0
- package/src/data/sqlite-retry.ts +66 -0
- package/src/data/ticker-store.ts +29 -18
- package/src/index.tsx +6 -1
- package/src/market-data/coordinator.test.ts +114 -5
- package/src/market-data/coordinator.ts +70 -1
- package/src/market-data/hooks.test.tsx +73 -2
- package/src/market-data/hooks.tsx +16 -4
- package/src/notifications/app-notifier.test.ts +177 -0
- package/src/notifications/app-notifier.ts +241 -0
- package/src/pane-settings.test.ts +2 -0
- package/src/plugins/builtin/ai/index.test.tsx +1 -1
- package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
- package/src/plugins/builtin/ai/ticker-context.ts +3 -2
- package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
- package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
- package/src/plugins/builtin/alerts/index.test.tsx +357 -0
- package/src/plugins/builtin/alerts/index.tsx +519 -0
- package/src/plugins/builtin/alerts/types.ts +14 -0
- package/src/plugins/builtin/analytics/index.test.tsx +300 -0
- package/src/plugins/builtin/analytics/index.tsx +775 -0
- package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
- package/src/plugins/builtin/analytics/metrics.ts +155 -0
- package/src/plugins/builtin/ask-ai.test.tsx +2 -0
- package/src/plugins/builtin/chat-controller.test.ts +83 -18
- package/src/plugins/builtin/chat-controller.ts +26 -14
- package/src/plugins/builtin/chat.test.tsx +715 -4
- package/src/plugins/builtin/chat.tsx +424 -120
- package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
- package/src/plugins/builtin/comparison-chart.tsx +1 -2
- package/src/plugins/builtin/correlation/compute.test.ts +147 -0
- package/src/plugins/builtin/correlation/compute.ts +115 -0
- package/src/plugins/builtin/correlation/index.tsx +385 -0
- package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
- package/src/plugins/builtin/correlation/settings.test.ts +34 -0
- package/src/plugins/builtin/correlation/settings.ts +95 -0
- package/src/plugins/builtin/debug.tsx +36 -50
- package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
- package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
- package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
- package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
- package/src/plugins/builtin/earnings/index.tsx +337 -0
- package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
- package/src/plugins/builtin/econ/calendar-source.ts +121 -0
- package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
- package/src/plugins/builtin/econ/fred-client.ts +79 -0
- package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
- package/src/plugins/builtin/econ/index.tsx +999 -0
- package/src/plugins/builtin/econ/types.ts +13 -0
- package/src/plugins/builtin/fred-settings.ts +10 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
- package/src/plugins/builtin/help.test.tsx +1 -5
- package/src/plugins/builtin/help.tsx +1 -16
- package/src/plugins/builtin/insider/index.tsx +417 -0
- package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
- package/src/plugins/builtin/insider/insider-data.ts +49 -0
- package/src/plugins/builtin/layout-manager.tsx +24 -16
- package/src/plugins/builtin/market-movers/index.tsx +573 -0
- package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
- package/src/plugins/builtin/market-movers/screener.ts +105 -0
- package/src/plugins/builtin/options.tsx +4 -3
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
- package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
- package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
- package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
- package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
- package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
- package/src/plugins/builtin/sec.tsx +114 -7
- package/src/plugins/builtin/sectors/index.tsx +359 -0
- package/src/plugins/builtin/sectors/sector-data.ts +18 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
- package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
- package/src/plugins/builtin/world-indices/index.tsx +410 -0
- package/src/plugins/builtin/world-indices/indices.ts +52 -0
- package/src/plugins/builtin/yield-curve/index.tsx +242 -0
- package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
- package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
- package/src/plugins/catalog.ts +22 -0
- package/src/plugins/gridlock-notification.ts +15 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
- package/src/plugins/ibkr/index.test.tsx +1 -1
- package/src/plugins/ibkr/index.tsx +1 -1
- package/src/plugins/ibkr/trade-tab.tsx +23 -5
- package/src/plugins/ibkr/trade-utils.test.ts +1 -1
- package/src/plugins/ibkr/trade-utils.ts +7 -6
- package/src/plugins/ibkr/trading-pane.tsx +8 -7
- package/src/plugins/plugin-runtime.test.tsx +2 -0
- package/src/plugins/plugin-runtime.tsx +10 -0
- package/src/plugins/prediction-markets/controller-data.ts +8 -0
- package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
- package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
- package/src/plugins/prediction-markets/services/fetch.ts +13 -7
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
- package/src/plugins/registry.ts +22 -13
- package/src/sources/provider-router.test.ts +3 -2
- package/src/sources/sec-edgar.ts +13 -1
- package/src/sources/yahoo-finance.ts +75 -1
- package/src/state/app-context-selector.test.tsx +17 -1
- package/src/state/app-context.test.tsx +24 -0
- package/src/state/app-context.tsx +29 -6
- package/src/theme/colors.ts +5 -0
- package/src/theme/themes.ts +25 -0
- package/src/types/config.test.ts +107 -0
- package/src/types/config.ts +30 -1
- package/src/types/data-provider.ts +13 -0
- package/src/types/plugin.ts +35 -1
- package/src/utils/api-client.test.ts +90 -0
- package/src/utils/api-client.ts +56 -16
- package/src/utils/format.test.ts +11 -1
- package/src/utils/format.ts +86 -8
- package/src/utils/inline-content-tokenizer.test.ts +22 -0
- package/src/utils/inline-content-tokenizer.ts +76 -0
- package/src/utils/link-tokenizer.test.ts +27 -0
- package/src/utils/link-tokenizer.ts +93 -0
- package/src/utils/market-format.test.ts +109 -0
- package/src/utils/market-format.ts +299 -0
- package/src/utils/market-status.ts +3 -2
- package/src/utils/selection-clipboard.test.ts +43 -0
- package/src/utils/selection-clipboard.ts +88 -0
- package/src/utils/terminal-input-reset.test.ts +50 -0
- package/src/utils/terminal-input-reset.ts +24 -0
- package/src/utils/throttled-fetch.test.ts +145 -0
- package/src/utils/throttled-fetch.ts +212 -0
- package/src/utils/ticker-tokenizer.test.ts +31 -0
- package/src/utils/ticker-tokenizer.ts +7 -42
- 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
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
+
}
|