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
package/src/index.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync } from "fs";
|
|
|
7
7
|
import { dispatchCli } from "./cli/index";
|
|
8
8
|
import { loadExternalPlugins } from "./plugins/loader";
|
|
9
9
|
import { debugLog } from "./utils/debug-log";
|
|
10
|
+
import { resetTerminalInputState } from "./utils/terminal-input-reset";
|
|
10
11
|
|
|
11
12
|
async function main() {
|
|
12
13
|
// Intercept console.log/warn/error to capture in debug log
|
|
@@ -43,6 +44,10 @@ async function main() {
|
|
|
43
44
|
// Load or create config
|
|
44
45
|
const config = await initDataDir(dataDir);
|
|
45
46
|
|
|
47
|
+
// `bun run --watch` does not guarantee the previous run's renderer can cleanly
|
|
48
|
+
// restore mouse/raw terminal modes before the next run starts.
|
|
49
|
+
resetTerminalInputState();
|
|
50
|
+
|
|
46
51
|
// Create renderer
|
|
47
52
|
const renderer = await createCliRenderer({
|
|
48
53
|
exitOnCtrlC: true,
|
|
@@ -63,5 +68,5 @@ async function main() {
|
|
|
63
68
|
|
|
64
69
|
main().catch((err) => {
|
|
65
70
|
console.error("Fatal error:", err);
|
|
66
|
-
process.
|
|
71
|
+
process.exitCode = 1;
|
|
67
72
|
});
|
|
@@ -92,6 +92,115 @@ describe("MarketDataCoordinator", () => {
|
|
|
92
92
|
expect(second.lastGoodData?.length).toBe(2);
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
it("uses cached chart data while a wider range request is loading", async () => {
|
|
96
|
+
const oneYearHistory = [
|
|
97
|
+
{ date: new Date("2024-01-01"), close: 100 },
|
|
98
|
+
{ date: new Date("2024-01-02"), close: 101 },
|
|
99
|
+
];
|
|
100
|
+
const fiveYearHistory = [
|
|
101
|
+
{ date: new Date("2020-01-01"), close: 80 },
|
|
102
|
+
...oneYearHistory,
|
|
103
|
+
];
|
|
104
|
+
let requestedFiveYear = false;
|
|
105
|
+
let resolveFiveYear: (history: PricePoint[] | PromiseLike<PricePoint[]>) => void = (_history) => {
|
|
106
|
+
throw new Error("expected pending 5Y request");
|
|
107
|
+
};
|
|
108
|
+
const provider = createProvider({
|
|
109
|
+
getPriceHistory: async (_symbol, _exchange, range) => {
|
|
110
|
+
if (range === "5Y") {
|
|
111
|
+
return new Promise<PricePoint[]>((resolve) => {
|
|
112
|
+
requestedFiveYear = true;
|
|
113
|
+
resolveFiveYear = resolve;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return oneYearHistory;
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
120
|
+
const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
|
|
121
|
+
|
|
122
|
+
await coordinator.loadChart({
|
|
123
|
+
instrument,
|
|
124
|
+
bufferRange: "1Y",
|
|
125
|
+
granularity: "range",
|
|
126
|
+
});
|
|
127
|
+
const pending = coordinator.loadChart({
|
|
128
|
+
instrument,
|
|
129
|
+
bufferRange: "5Y",
|
|
130
|
+
granularity: "range",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const loadingEntry = coordinator.getChartEntry({
|
|
134
|
+
instrument,
|
|
135
|
+
bufferRange: "5Y",
|
|
136
|
+
granularity: "range",
|
|
137
|
+
});
|
|
138
|
+
expect(loadingEntry.phase).toBe("refreshing");
|
|
139
|
+
expect(loadingEntry.data?.map((point) => point.close)).toEqual([100, 101]);
|
|
140
|
+
|
|
141
|
+
expect(requestedFiveYear).toBe(true);
|
|
142
|
+
resolveFiveYear(fiveYearHistory);
|
|
143
|
+
const readyEntry = await pending;
|
|
144
|
+
expect(readyEntry.phase).toBe("ready");
|
|
145
|
+
expect(readyEntry.data?.map((point) => point.close)).toEqual([80, 100, 101]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("uses cached manual-resolution data while a wider resolution range is loading", async () => {
|
|
149
|
+
const oneYearHistory = [
|
|
150
|
+
{ date: new Date("2024-01-01"), close: 100 },
|
|
151
|
+
{ date: new Date("2024-01-02"), close: 101 },
|
|
152
|
+
];
|
|
153
|
+
const fiveYearHistory = [
|
|
154
|
+
{ date: new Date("2020-01-01"), close: 80 },
|
|
155
|
+
...oneYearHistory,
|
|
156
|
+
];
|
|
157
|
+
let requestedFiveYear = false;
|
|
158
|
+
let resolveFiveYear: (history: PricePoint[] | PromiseLike<PricePoint[]>) => void = (_history) => {
|
|
159
|
+
throw new Error("expected pending 5Y request");
|
|
160
|
+
};
|
|
161
|
+
const provider = createProvider({
|
|
162
|
+
getPriceHistoryForResolution: async (_symbol, _exchange, range) => {
|
|
163
|
+
if (range === "5Y") {
|
|
164
|
+
return new Promise<PricePoint[]>((resolve) => {
|
|
165
|
+
requestedFiveYear = true;
|
|
166
|
+
resolveFiveYear = resolve;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return oneYearHistory;
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
173
|
+
const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
|
|
174
|
+
|
|
175
|
+
await coordinator.loadChart({
|
|
176
|
+
instrument,
|
|
177
|
+
bufferRange: "1Y",
|
|
178
|
+
granularity: "resolution",
|
|
179
|
+
resolution: "1d",
|
|
180
|
+
});
|
|
181
|
+
const pending = coordinator.loadChart({
|
|
182
|
+
instrument,
|
|
183
|
+
bufferRange: "5Y",
|
|
184
|
+
granularity: "resolution",
|
|
185
|
+
resolution: "1d",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const loadingEntry = coordinator.getChartEntry({
|
|
189
|
+
instrument,
|
|
190
|
+
bufferRange: "5Y",
|
|
191
|
+
granularity: "resolution",
|
|
192
|
+
resolution: "1d",
|
|
193
|
+
});
|
|
194
|
+
expect(loadingEntry.phase).toBe("refreshing");
|
|
195
|
+
expect(loadingEntry.data?.map((point) => point.close)).toEqual([100, 101]);
|
|
196
|
+
|
|
197
|
+
expect(requestedFiveYear).toBe(true);
|
|
198
|
+
resolveFiveYear(fiveYearHistory);
|
|
199
|
+
const readyEntry = await pending;
|
|
200
|
+
expect(readyEntry.phase).toBe("ready");
|
|
201
|
+
expect(readyEntry.data?.map((point) => point.close)).toEqual([80, 100, 101]);
|
|
202
|
+
});
|
|
203
|
+
|
|
95
204
|
it("normalizes descending chart history before storing it", async () => {
|
|
96
205
|
const provider = createProvider({
|
|
97
206
|
getPriceHistory: async () => [
|
|
@@ -124,7 +233,7 @@ describe("MarketDataCoordinator", () => {
|
|
|
124
233
|
const instrument = { symbol: "MSFT", exchange: "NASDAQ" };
|
|
125
234
|
|
|
126
235
|
coordinator.subscribeQuotes([{ instrument }]);
|
|
127
|
-
const onStreamed = streamed;
|
|
236
|
+
const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
|
|
128
237
|
if (!onStreamed) throw new Error("expected streaming callback");
|
|
129
238
|
onStreamed(
|
|
130
239
|
{ symbol: "MSFT", exchange: "NASDAQ" },
|
|
@@ -162,7 +271,7 @@ describe("MarketDataCoordinator", () => {
|
|
|
162
271
|
resolution: "1d",
|
|
163
272
|
});
|
|
164
273
|
|
|
165
|
-
expect(requested).toEqual({ range: "1Y", resolution: "1d" });
|
|
274
|
+
expect(requested as { range: string; resolution: string } | null).toEqual({ range: "1Y", resolution: "1d" });
|
|
166
275
|
expect(entry.data?.length).toBe(2);
|
|
167
276
|
});
|
|
168
277
|
|
|
@@ -196,7 +305,7 @@ describe("MarketDataCoordinator", () => {
|
|
|
196
305
|
|
|
197
306
|
await coordinator.loadSnapshot(instrument);
|
|
198
307
|
coordinator.subscribeQuotes([{ instrument }]);
|
|
199
|
-
const onStreamed = streamed;
|
|
308
|
+
const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
|
|
200
309
|
if (!onStreamed) throw new Error("expected streaming callback");
|
|
201
310
|
onStreamed(
|
|
202
311
|
{
|
|
@@ -292,7 +401,7 @@ describe("MarketDataCoordinator", () => {
|
|
|
292
401
|
|
|
293
402
|
await coordinator.loadSnapshot(instrument);
|
|
294
403
|
coordinator.subscribeQuotes([{ instrument }]);
|
|
295
|
-
const onStreamed = streamed;
|
|
404
|
+
const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
|
|
296
405
|
if (!onStreamed) throw new Error("expected streaming callback");
|
|
297
406
|
onStreamed(
|
|
298
407
|
{ symbol: "IQE", exchange: "LSE" },
|
|
@@ -347,7 +456,7 @@ describe("MarketDataCoordinator", () => {
|
|
|
347
456
|
|
|
348
457
|
await coordinator.loadSnapshot(instrument);
|
|
349
458
|
coordinator.subscribeQuotes([{ instrument }]);
|
|
350
|
-
const onStreamed = streamed;
|
|
459
|
+
const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
|
|
351
460
|
if (!onStreamed) throw new Error("expected streaming callback");
|
|
352
461
|
onStreamed(
|
|
353
462
|
{ symbol: "HY9H", exchange: "FWB2" },
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
buildArticleSummaryKey,
|
|
9
9
|
buildChartKey,
|
|
10
10
|
buildFxKey,
|
|
11
|
+
buildInstrumentKey,
|
|
11
12
|
buildTickerFinancialsSnapshot,
|
|
12
13
|
buildNewsKey,
|
|
13
14
|
buildOptionsKey,
|
|
@@ -24,9 +25,11 @@ import {
|
|
|
24
25
|
import { traceMarketData } from "./trace";
|
|
25
26
|
import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
|
|
26
27
|
import { hasFreshQuoteForCurrentSession, isQuoteStaleForCurrentSession } from "../utils/quote-freshness";
|
|
28
|
+
import { TIME_RANGE_ORDER } from "../components/chart/chart-resolution";
|
|
27
29
|
|
|
28
30
|
const EMPTY_MESSAGE = "No data available";
|
|
29
31
|
const EXPECTED_EMPTY = /no data|not found|delisted|unavailable|unsupported/i;
|
|
32
|
+
const TIME_RANGE_INDEX = new Map(TIME_RANGE_ORDER.map((range, index) => [range, index]));
|
|
30
33
|
|
|
31
34
|
function createBaselineChartRequest(instrument: InstrumentRef): ChartRequest {
|
|
32
35
|
return {
|
|
@@ -36,6 +39,27 @@ function createBaselineChartRequest(instrument: InstrumentRef): ChartRequest {
|
|
|
36
39
|
};
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
function getChartGranularity(request: ChartRequest): NonNullable<ChartRequest["granularity"]> {
|
|
43
|
+
return request.granularity ?? "range";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getTimeRangeIndex(range: ChartRequest["bufferRange"]): number {
|
|
47
|
+
return TIME_RANGE_INDEX.get(range) ?? 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isSeedableChartRequest(
|
|
51
|
+
target: ChartRequest,
|
|
52
|
+
candidate: ChartRequest,
|
|
53
|
+
): boolean {
|
|
54
|
+
const targetGranularity = getChartGranularity(target);
|
|
55
|
+
const candidateGranularity = getChartGranularity(candidate);
|
|
56
|
+
if (targetGranularity !== candidateGranularity) return false;
|
|
57
|
+
if (targetGranularity === "detail") return false;
|
|
58
|
+
if (targetGranularity === "resolution" && target.resolution !== candidate.resolution) return false;
|
|
59
|
+
if (buildInstrumentKey(target.instrument) !== buildInstrumentKey(candidate.instrument)) return false;
|
|
60
|
+
return getTimeRangeIndex(candidate.bufferRange) <= getTimeRangeIndex(target.bufferRange);
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
function classifyError(error: unknown): { reasonCode: ProviderReasonCode; message: string } {
|
|
40
64
|
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
41
65
|
if (/timeout/i.test(message)) return { reasonCode: "TIMEOUT", message };
|
|
@@ -125,6 +149,7 @@ export class MarketDataCoordinator {
|
|
|
125
149
|
private version = 0;
|
|
126
150
|
private readonly listeners = new Set<() => void>();
|
|
127
151
|
private readonly inFlight = new Map<string, Promise<unknown>>();
|
|
152
|
+
private readonly chartRequests = new Map<string, ChartRequest>();
|
|
128
153
|
|
|
129
154
|
private readonly quoteStore = new QueryStore<Quote>(() => this.bump());
|
|
130
155
|
private readonly snapshotStore = new QueryStore<TickerFinancials>(() => this.bump());
|
|
@@ -281,6 +306,49 @@ export class MarketDataCoordinator {
|
|
|
281
306
|
return promise;
|
|
282
307
|
}
|
|
283
308
|
|
|
309
|
+
private findChartSeedEntry(
|
|
310
|
+
key: string,
|
|
311
|
+
request: ChartRequest,
|
|
312
|
+
): { entry: QueryEntry<PricePoint[]>; data: PricePoint[]; score: number } | null {
|
|
313
|
+
let best: { entry: QueryEntry<PricePoint[]>; data: PricePoint[]; score: number } | null = null;
|
|
314
|
+
for (const [candidateKey, candidateRequest] of this.chartRequests) {
|
|
315
|
+
if (candidateKey === key) continue;
|
|
316
|
+
if (!isSeedableChartRequest(request, candidateRequest)) continue;
|
|
317
|
+
const entry = this.chartStore.get(candidateKey);
|
|
318
|
+
const data = resolveEntryData(entry);
|
|
319
|
+
if (!data?.length) continue;
|
|
320
|
+
const score = getTimeRangeIndex(candidateRequest.bufferRange);
|
|
321
|
+
if (!best || score > best.score) {
|
|
322
|
+
best = { entry, data, score };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return best;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private createChartLoadingEntry(
|
|
329
|
+
key: string,
|
|
330
|
+
request: ChartRequest,
|
|
331
|
+
current: QueryEntry<PricePoint[]>,
|
|
332
|
+
): QueryEntry<PricePoint[]> {
|
|
333
|
+
if (resolveEntryData(current)?.length) {
|
|
334
|
+
return loadingEntry(current);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const seed = this.findChartSeedEntry(key, request);
|
|
338
|
+
if (!seed) {
|
|
339
|
+
return loadingEntry(current);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return loadingEntry({
|
|
343
|
+
...current,
|
|
344
|
+
data: seed.data,
|
|
345
|
+
lastGoodData: seed.entry.lastGoodData?.length ? seed.entry.lastGoodData : seed.data,
|
|
346
|
+
source: seed.entry.source,
|
|
347
|
+
fetchedAt: seed.entry.fetchedAt,
|
|
348
|
+
staleAt: seed.entry.staleAt,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
284
352
|
async loadSnapshot(
|
|
285
353
|
instrument: InstrumentRef,
|
|
286
354
|
options: { forceRefresh?: boolean } = {},
|
|
@@ -379,9 +447,10 @@ export class MarketDataCoordinator {
|
|
|
379
447
|
options: { forceRefresh?: boolean } = {},
|
|
380
448
|
): Promise<QueryEntry<PricePoint[]>> {
|
|
381
449
|
const key = buildChartKey(request);
|
|
450
|
+
this.chartRequests.set(key, request);
|
|
382
451
|
const flightKey = options.forceRefresh ? `${key}|refresh` : key;
|
|
383
452
|
return this.runSingleFlight(flightKey, async () => {
|
|
384
|
-
this.chartStore.update(key,
|
|
453
|
+
this.chartStore.update(key, (current) => this.createChartLoadingEntry(key, request, current));
|
|
385
454
|
const startedAt = Date.now();
|
|
386
455
|
try {
|
|
387
456
|
const data = normalizePriceHistory(
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { act, useState } from "react";
|
|
3
3
|
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
-
import type { TickerFinancials } from "../types/financials";
|
|
4
|
+
import type { PricePoint, TickerFinancials } from "../types/financials";
|
|
5
5
|
import type { TickerRecord } from "../types/ticker";
|
|
6
6
|
import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "./coordinator";
|
|
7
|
-
import { useFxRatesMap, useTickerFinancialsMap } from "./hooks";
|
|
7
|
+
import { useChartQueries, useFxRatesMap, useTickerFinancialsMap } from "./hooks";
|
|
8
|
+
import type { ChartRequest } from "./request-types";
|
|
9
|
+
import { createIdleEntry } from "./result-types";
|
|
8
10
|
|
|
9
11
|
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
10
12
|
let bumpHarness: (() => void) | null = null;
|
|
13
|
+
let replaceChartRequests: ((requests: readonly ChartRequest[]) => void) | null = null;
|
|
11
14
|
let latestFxRates: Map<string, number> | null = null;
|
|
12
15
|
let latestFinancialsMap: Map<string, TickerFinancials> | null = null;
|
|
13
16
|
|
|
@@ -70,6 +73,31 @@ function HooksHarness() {
|
|
|
70
73
|
return <text>{String(tick)}</text>;
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
function ChartQueriesHarness({
|
|
77
|
+
initialRequests,
|
|
78
|
+
debounceMs,
|
|
79
|
+
}: {
|
|
80
|
+
initialRequests: readonly ChartRequest[];
|
|
81
|
+
debounceMs: number;
|
|
82
|
+
}) {
|
|
83
|
+
const [requests, setRequests] = useState<readonly ChartRequest[]>(initialRequests);
|
|
84
|
+
replaceChartRequests = setRequests;
|
|
85
|
+
useChartQueries(requests, { debounceMs });
|
|
86
|
+
|
|
87
|
+
return <text>{String(requests.length)}</text>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function makeChartRequest(bufferRange: ChartRequest["bufferRange"]): ChartRequest {
|
|
91
|
+
return {
|
|
92
|
+
instrument: {
|
|
93
|
+
symbol: "AAPL",
|
|
94
|
+
exchange: "NASDAQ",
|
|
95
|
+
},
|
|
96
|
+
bufferRange,
|
|
97
|
+
granularity: "range",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
73
101
|
afterEach(async () => {
|
|
74
102
|
if (testSetup) {
|
|
75
103
|
await act(async () => {
|
|
@@ -78,6 +106,7 @@ afterEach(async () => {
|
|
|
78
106
|
testSetup = undefined;
|
|
79
107
|
}
|
|
80
108
|
bumpHarness = null;
|
|
109
|
+
replaceChartRequests = null;
|
|
81
110
|
latestFxRates = null;
|
|
82
111
|
latestFinancialsMap = null;
|
|
83
112
|
setSharedMarketDataCoordinator(null);
|
|
@@ -117,4 +146,46 @@ describe("market-data hooks", () => {
|
|
|
117
146
|
expect(latestFxRates).toBe(initialFxRates);
|
|
118
147
|
expect(latestFinancialsMap).toBe(initialFinancialsMap);
|
|
119
148
|
});
|
|
149
|
+
|
|
150
|
+
test("debounces chart query batches and cancels superseded schedules", async () => {
|
|
151
|
+
const loadedRanges: string[] = [];
|
|
152
|
+
const idleChartEntry = createIdleEntry<PricePoint[]>();
|
|
153
|
+
const coordinator = {
|
|
154
|
+
subscribe: () => () => {},
|
|
155
|
+
getVersion: () => 1,
|
|
156
|
+
getChartEntry: () => idleChartEntry,
|
|
157
|
+
loadChart: async (request: ChartRequest) => {
|
|
158
|
+
loadedRanges.push(request.bufferRange);
|
|
159
|
+
return idleChartEntry;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
|
|
163
|
+
|
|
164
|
+
testSetup = await testRender(
|
|
165
|
+
<ChartQueriesHarness initialRequests={[makeChartRequest("1D")]} debounceMs={40} />,
|
|
166
|
+
{
|
|
167
|
+
width: 20,
|
|
168
|
+
height: 1,
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await act(async () => {
|
|
173
|
+
await testSetup!.renderOnce();
|
|
174
|
+
});
|
|
175
|
+
await act(async () => {
|
|
176
|
+
replaceChartRequests?.([makeChartRequest("1W")]);
|
|
177
|
+
await testSetup!.renderOnce();
|
|
178
|
+
});
|
|
179
|
+
await act(async () => {
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
181
|
+
replaceChartRequests?.([makeChartRequest("1M")]);
|
|
182
|
+
await testSetup!.renderOnce();
|
|
183
|
+
});
|
|
184
|
+
await act(async () => {
|
|
185
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
186
|
+
await testSetup!.renderOnce();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(loadedRanges).toEqual(["1M"]);
|
|
190
|
+
});
|
|
120
191
|
});
|
|
@@ -121,8 +121,12 @@ export function useChartQuery(request: ChartRequest | null | undefined): QueryEn
|
|
|
121
121
|
return entry;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
export function useChartQueries(
|
|
124
|
+
export function useChartQueries(
|
|
125
|
+
requests: readonly ChartRequest[],
|
|
126
|
+
options: { debounceMs?: number } = {},
|
|
127
|
+
): Map<string, QueryEntry<PricePoint[]>> {
|
|
125
128
|
const requestKey = requests.map((request) => buildChartKey(request)).join(",");
|
|
129
|
+
const debounceMs = Math.max(0, options.debounceMs ?? 0);
|
|
126
130
|
const entries = useCoordinatorSelector((coordinator) => (
|
|
127
131
|
requests.map((request) => [buildChartKey(request), coordinator.getChartEntry(request)] as const)
|
|
128
132
|
), [] as Array<readonly [string, QueryEntry<PricePoint[]>]>);
|
|
@@ -130,10 +134,18 @@ export function useChartQueries(requests: readonly ChartRequest[]): Map<string,
|
|
|
130
134
|
useEffect(() => {
|
|
131
135
|
const coordinator = getSharedMarketDataCoordinator();
|
|
132
136
|
if (!coordinator) return;
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
const loadRequests = () => {
|
|
138
|
+
for (const request of requests) {
|
|
139
|
+
void coordinator.loadChart(request);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
if (debounceMs <= 0) {
|
|
143
|
+
loadRequests();
|
|
144
|
+
return;
|
|
135
145
|
}
|
|
136
|
-
|
|
146
|
+
const timeout = setTimeout(loadRequests, debounceMs);
|
|
147
|
+
return () => clearTimeout(timeout);
|
|
148
|
+
}, [debounceMs, requestKey]);
|
|
137
149
|
|
|
138
150
|
return useMemo(() => new Map(entries), [entries]);
|
|
139
151
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildDesktopNotificationCommand,
|
|
4
|
+
buildSoundCommand,
|
|
5
|
+
createAppNotifier,
|
|
6
|
+
createDesktopNotifier,
|
|
7
|
+
} from "./app-notifier";
|
|
8
|
+
|
|
9
|
+
describe("desktop notification commands", () => {
|
|
10
|
+
test("builds a macOS notification command", () => {
|
|
11
|
+
const command = buildDesktopNotificationCommand({
|
|
12
|
+
title: "Chat mention",
|
|
13
|
+
body: "@bob mentioned you",
|
|
14
|
+
subtitle: "#everyone",
|
|
15
|
+
}, "darwin");
|
|
16
|
+
|
|
17
|
+
expect(command).toEqual({
|
|
18
|
+
command: "osascript",
|
|
19
|
+
args: ["-e", "display notification \"@bob mentioned you\" with title \"Chat mention\" subtitle \"#everyone\""],
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("builds a Linux notification command", () => {
|
|
24
|
+
const command = buildDesktopNotificationCommand({
|
|
25
|
+
title: "Chat mention",
|
|
26
|
+
body: "@bob mentioned you",
|
|
27
|
+
subtitle: "#everyone",
|
|
28
|
+
type: "error",
|
|
29
|
+
}, "linux");
|
|
30
|
+
|
|
31
|
+
expect(command).toEqual({
|
|
32
|
+
command: "notify-send",
|
|
33
|
+
args: ["--app-name", "Gloomberb", "--urgency", "critical", "Chat mention", "#everyone\n@bob mentioned you"],
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("builds a Windows notification command", () => {
|
|
38
|
+
const command = buildDesktopNotificationCommand({
|
|
39
|
+
title: "Chat mention",
|
|
40
|
+
body: "@bob mentioned you",
|
|
41
|
+
subtitle: "#everyone",
|
|
42
|
+
}, "win32");
|
|
43
|
+
|
|
44
|
+
expect(command?.command).toBe("powershell.exe");
|
|
45
|
+
expect(command?.args[0]).toBe("-NoProfile");
|
|
46
|
+
expect(command?.args.at(-1)).toContain("$notify.BalloonTipTitle = 'Chat mention';");
|
|
47
|
+
expect(command?.args.at(-1)).toContain("$notify.BalloonTipText = '#everyone\n@bob mentioned you';");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("adds macOS notification sound when requested", () => {
|
|
51
|
+
const command = buildDesktopNotificationCommand({
|
|
52
|
+
title: "Price alert",
|
|
53
|
+
body: "AAPL triggered",
|
|
54
|
+
sound: "Glass",
|
|
55
|
+
}, "darwin");
|
|
56
|
+
|
|
57
|
+
expect(command?.args[1]).toContain("sound name \"Glass\"");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("notification sound commands", () => {
|
|
62
|
+
test("builds platform sound commands", () => {
|
|
63
|
+
expect(buildSoundCommand("Glass", "darwin")).toEqual({
|
|
64
|
+
command: "afplay",
|
|
65
|
+
args: ["/System/Library/Sounds/Glass.aiff"],
|
|
66
|
+
});
|
|
67
|
+
expect(buildSoundCommand("Ping", "linux")).toEqual({
|
|
68
|
+
command: "paplay",
|
|
69
|
+
args: ["/usr/share/sounds/freedesktop/stereo/message-new-instant.oga"],
|
|
70
|
+
});
|
|
71
|
+
expect(buildSoundCommand("Glass", "win32")?.command).toBe("powershell.exe");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("app notifier", () => {
|
|
76
|
+
test("shows a toast and sends a desktop notification when inactive", () => {
|
|
77
|
+
const toasts: Array<{ message: string; type?: string; duration?: number }> = [];
|
|
78
|
+
const desktops: string[] = [];
|
|
79
|
+
let active = false;
|
|
80
|
+
const notifier = createAppNotifier({
|
|
81
|
+
isAppActive: () => active,
|
|
82
|
+
renderToast: (notification) => {
|
|
83
|
+
toasts.push({
|
|
84
|
+
message: notification.body,
|
|
85
|
+
type: notification.type,
|
|
86
|
+
duration: notification.duration,
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
desktop: {
|
|
90
|
+
notify(notification) {
|
|
91
|
+
desktops.push(notification.body);
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
notifier.notify({
|
|
97
|
+
title: "Chat mention",
|
|
98
|
+
body: "@bob mentioned you",
|
|
99
|
+
type: "info",
|
|
100
|
+
desktop: "when-inactive",
|
|
101
|
+
duration: 5000,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(toasts).toEqual([{ message: "@bob mentioned you", type: "info", duration: 5000 }]);
|
|
105
|
+
expect(desktops).toEqual(["@bob mentioned you"]);
|
|
106
|
+
|
|
107
|
+
active = true;
|
|
108
|
+
notifier.notify({
|
|
109
|
+
body: "Visible toast only",
|
|
110
|
+
type: "success",
|
|
111
|
+
desktop: "when-inactive",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(toasts).toEqual([
|
|
115
|
+
{ message: "@bob mentioned you", type: "info", duration: 5000 },
|
|
116
|
+
{ message: "Visible toast only", type: "success", duration: undefined },
|
|
117
|
+
]);
|
|
118
|
+
expect(desktops).toEqual(["@bob mentioned you"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("toast-disabled notifications skip toast delivery", () => {
|
|
122
|
+
const toasts: string[] = [];
|
|
123
|
+
const desktops: string[] = [];
|
|
124
|
+
const notifier = createAppNotifier({
|
|
125
|
+
isAppActive: () => false,
|
|
126
|
+
renderToast: (notification) => {
|
|
127
|
+
toasts.push(notification.body);
|
|
128
|
+
},
|
|
129
|
+
desktop: {
|
|
130
|
+
notify(notification) {
|
|
131
|
+
desktops.push(notification.body);
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
notifier.notify({ body: "Saved", type: "success", toast: false, desktop: "when-inactive" });
|
|
137
|
+
|
|
138
|
+
expect(toasts).toEqual([]);
|
|
139
|
+
expect(desktops).toEqual(["Saved"]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("desktop notifier", () => {
|
|
144
|
+
test("stops retrying a missing desktop notification command", () => {
|
|
145
|
+
const calls: string[] = [];
|
|
146
|
+
const notifier = createDesktopNotifier({
|
|
147
|
+
platform: "linux",
|
|
148
|
+
runner: {
|
|
149
|
+
run(command, _args, handlers) {
|
|
150
|
+
calls.push(command);
|
|
151
|
+
handlers?.onError?.(Object.assign(new Error("missing"), { code: "ENOENT" }));
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
notifier.notify({ body: "first" });
|
|
157
|
+
notifier.notify({ body: "second" });
|
|
158
|
+
|
|
159
|
+
expect(calls).toEqual(["notify-send"]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("plays requested sounds alongside desktop notifications", () => {
|
|
163
|
+
const calls: string[] = [];
|
|
164
|
+
const notifier = createDesktopNotifier({
|
|
165
|
+
platform: "linux",
|
|
166
|
+
runner: {
|
|
167
|
+
run(command) {
|
|
168
|
+
calls.push(command);
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
notifier.notify({ body: "AAPL triggered", sound: "Glass" });
|
|
174
|
+
|
|
175
|
+
expect(calls).toEqual(["notify-send", "paplay"]);
|
|
176
|
+
});
|
|
177
|
+
});
|