gloomberb 0.3.7 → 0.4.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 +19 -8
- package/package.json +1 -1
- package/src/app.tsx +45 -69
- package/src/cli/base-converter.ts +31 -0
- package/src/cli/commands/search.ts +11 -3
- package/src/cli/commands/ticker.ts +22 -11
- package/src/cli/context.ts +27 -32
- package/src/cli/helpers.ts +1 -4
- package/src/cli/index.ts +140 -123
- package/src/cli/registry.test.ts +226 -0
- package/src/cli/registry.ts +201 -0
- package/src/cli.test.ts +219 -0
- package/src/components/chart/chart-controller.ts +309 -0
- package/src/components/chart/chart-data.ts +5 -6
- package/src/components/chart/chart-pane-settings.ts +27 -0
- package/src/components/chart/chart-resolution.test.ts +56 -0
- package/src/components/chart/chart-resolution.ts +362 -0
- package/src/components/chart/chart-types.ts +12 -4
- package/src/components/chart/chart-viewport.test.ts +25 -0
- package/src/components/chart/chart-viewport.ts +28 -0
- package/src/components/chart/comparison-chart-data.ts +20 -27
- package/src/components/chart/comparison-stock-chart.tsx +374 -99
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
- package/src/components/chart/stock-chart.tsx +455 -106
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
- package/src/components/command-bar/command-bar.test.tsx +650 -5
- package/src/components/command-bar/command-bar.tsx +363 -63
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/command-bar/helpers.test.ts +23 -1
- package/src/components/command-bar/helpers.ts +14 -5
- package/src/components/command-bar/pane-template-display.ts +12 -0
- package/src/components/command-bar/root-shortcuts.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +4 -2
- package/src/components/detail-feed-view.test.tsx +124 -49
- package/src/components/detail-feed-view.tsx +396 -160
- package/src/components/layout/floating-pane.tsx +4 -2
- package/src/components/layout/header.tsx +3 -3
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/pane-sizing.ts +6 -0
- package/src/components/layout/pane.tsx +5 -1
- package/src/components/layout/shell.test.tsx +77 -5
- package/src/components/layout/shell.tsx +67 -52
- package/src/components/layout/status-bar.test.tsx +66 -0
- package/src/components/layout/status-bar.tsx +26 -5
- package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
- package/src/components/onboarding/onboarding-wizard.tsx +5 -6
- package/src/components/ticker-list-table.test.tsx +83 -0
- package/src/components/ticker-list-table.tsx +181 -97
- package/src/components/ui/data-table.tsx +50 -5
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/page-stack-view.tsx +60 -0
- package/src/components/use-double-click-activation.ts +31 -0
- package/src/index.tsx +10 -10
- package/src/market-data/coordinator.test.ts +92 -4
- package/src/market-data/coordinator.ts +32 -5
- package/src/market-data/hooks.test.tsx +120 -0
- package/src/market-data/hooks.tsx +53 -16
- package/src/market-data/request-types.ts +4 -2
- package/src/market-data/selectors.ts +24 -58
- package/src/plugins/builtin/chat-controller.test.ts +400 -3
- package/src/plugins/builtin/chat-controller.ts +328 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +180 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- package/src/plugins/builtin/layout-manager.tsx +9 -0
- package/src/plugins/builtin/news.tsx +1 -11
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
- package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
- package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
- package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
- package/src/plugins/builtin/sec.tsx +1 -11
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
- package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
- package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
- package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
- package/src/plugins/catalog.ts +65 -0
- package/src/plugins/ibkr/broker-adapter.ts +28 -0
- package/src/plugins/ibkr/gateway-service.test.ts +67 -1
- package/src/plugins/ibkr/gateway-service.ts +147 -7
- package/src/plugins/ibkr/index.tsx +7 -1
- package/src/plugins/pane-manager.test.ts +1 -1
- package/src/plugins/plugin-runtime.tsx +27 -7
- package/src/plugins/prediction-markets/cli.ts +28 -0
- package/src/plugins/prediction-markets/controller-data.ts +132 -17
- package/src/plugins/prediction-markets/controller.ts +142 -122
- package/src/plugins/prediction-markets/index.tsx +7 -1
- package/src/plugins/prediction-markets/launch.ts +51 -16
- package/src/plugins/prediction-markets/metrics.ts +9 -5
- package/src/plugins/prediction-markets/pane.test.tsx +197 -57
- package/src/plugins/prediction-markets/pane.tsx +80 -155
- package/src/plugins/prediction-markets/services/fetch.ts +4 -0
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
- package/src/plugins/prediction-markets/types.ts +0 -1
- package/src/plugins/registry.ts +23 -0
- package/src/sources/gloomberb-cloud.test.ts +29 -0
- package/src/sources/gloomberb-cloud.ts +89 -1
- package/src/sources/provider-router.test.ts +265 -3
- package/src/sources/provider-router.ts +239 -11
- package/src/sources/yahoo-finance.test.ts +26 -0
- package/src/sources/yahoo-finance.ts +54 -3
- package/src/state/app-context-selector.test.tsx +99 -0
- package/src/state/app-context.test.ts +18 -0
- package/src/state/app-context.tsx +170 -65
- package/src/state/selectors.ts +25 -0
- package/src/theme/colors.ts +8 -8
- package/src/theme/themes.test.ts +2 -0
- package/src/theme/themes.ts +22 -10
- package/src/types/broker.ts +21 -0
- package/src/types/config.ts +17 -1
- package/src/types/data-provider.ts +18 -0
- package/src/types/financials.ts +31 -1
- package/src/types/plugin.ts +70 -0
- package/src/updater.test.ts +2 -0
- package/src/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +47 -12
- package/src/utils/back-navigation.ts +27 -0
- package/src/utils/format.test.ts +14 -0
- package/src/utils/format.ts +3 -3
- package/src/utils/market-status.ts +46 -1
- package/src/utils/quote-freshness.ts +127 -0
- package/src/utils/quote-resolution.test.ts +277 -0
- package/src/utils/quote-resolution.ts +641 -0
- package/src/utils/slugify.ts +3 -0
- package/src/utils/timestamp.ts +25 -0
- package/src/version.ts +1 -1
- package/src/cli/commands/portfolio.ts +0 -210
- package/src/cli/commands/watchlist.ts +0 -210
- package/src/plugins/builtin/data-providers.ts +0 -16
- package/src/sources/exchange-rates.ts +0 -18
- package/src/utils/ascii-chart.ts +0 -172
- package/src/utils/ticker-tokenizer.test.ts +0 -48
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { colors } from "../../theme/colors";
|
|
4
|
+
import { isBackNavigationKey } from "../../utils/back-navigation";
|
|
5
|
+
|
|
6
|
+
interface PageStackViewProps {
|
|
7
|
+
focused: boolean;
|
|
8
|
+
detailOpen: boolean;
|
|
9
|
+
onBack: () => void;
|
|
10
|
+
rootContent: ReactNode;
|
|
11
|
+
detailContent: ReactNode;
|
|
12
|
+
backLabel?: string;
|
|
13
|
+
backHint?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function PageStackView({
|
|
17
|
+
focused,
|
|
18
|
+
detailOpen,
|
|
19
|
+
onBack,
|
|
20
|
+
rootContent,
|
|
21
|
+
detailContent,
|
|
22
|
+
backLabel = "Back",
|
|
23
|
+
backHint,
|
|
24
|
+
}: PageStackViewProps) {
|
|
25
|
+
useKeyboard((event) => {
|
|
26
|
+
if (!focused || !detailOpen || !isBackNavigationKey(event)) return;
|
|
27
|
+
event.stopPropagation?.();
|
|
28
|
+
event.preventDefault?.();
|
|
29
|
+
onBack();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!detailOpen) {
|
|
33
|
+
return (
|
|
34
|
+
<box flexDirection="column" flexGrow={1}>
|
|
35
|
+
{rootContent}
|
|
36
|
+
</box>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<box flexDirection="column" flexGrow={1}>
|
|
42
|
+
<box height={1} flexDirection="row">
|
|
43
|
+
<box
|
|
44
|
+
onMouseDown={(event) => {
|
|
45
|
+
event.preventDefault?.();
|
|
46
|
+
event.stopPropagation?.();
|
|
47
|
+
onBack();
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<text fg={colors.textBright}>{`<- ${backLabel}`}</text>
|
|
51
|
+
</box>
|
|
52
|
+
<box flexGrow={1} />
|
|
53
|
+
{backHint ? <text fg={colors.textMuted}>{backHint}</text> : null}
|
|
54
|
+
</box>
|
|
55
|
+
<box flexDirection="column" flexGrow={1}>
|
|
56
|
+
{detailContent}
|
|
57
|
+
</box>
|
|
58
|
+
</box>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
interface LastClickState {
|
|
4
|
+
targetKey: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function useDoubleClickActivation<T>({
|
|
8
|
+
onSelect,
|
|
9
|
+
onActivate,
|
|
10
|
+
}: {
|
|
11
|
+
onSelect?: (value: T) => void;
|
|
12
|
+
onActivate?: (value: T) => void;
|
|
13
|
+
}) {
|
|
14
|
+
const lastClickRef = useRef<LastClickState | null>(null);
|
|
15
|
+
|
|
16
|
+
return useCallback((targetKey: string, value: T) => {
|
|
17
|
+
const lastClick = lastClickRef.current;
|
|
18
|
+
|
|
19
|
+
onSelect?.(value);
|
|
20
|
+
|
|
21
|
+
if (lastClick && lastClick.targetKey === targetKey) {
|
|
22
|
+
lastClickRef.current = null;
|
|
23
|
+
onActivate?.(value);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
lastClickRef.current = {
|
|
28
|
+
targetKey,
|
|
29
|
+
};
|
|
30
|
+
}, [onActivate, onSelect]);
|
|
31
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -4,10 +4,9 @@ import { App } from "./app";
|
|
|
4
4
|
import { getDataDir, initDataDir } from "./data/config-store";
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
import { existsSync, mkdirSync } from "fs";
|
|
7
|
-
import {
|
|
7
|
+
import { dispatchCli } from "./cli/index";
|
|
8
8
|
import { loadExternalPlugins } from "./plugins/loader";
|
|
9
9
|
import { debugLog } from "./utils/debug-log";
|
|
10
|
-
import { parsePredictionLaunchArgs } from "./plugins/prediction-markets/launch";
|
|
11
10
|
|
|
12
11
|
async function main() {
|
|
13
12
|
// Intercept console.log/warn/error to capture in debug log
|
|
@@ -18,10 +17,14 @@ async function main() {
|
|
|
18
17
|
|
|
19
18
|
// Handle CLI subcommands (install, remove, update, plugins)
|
|
20
19
|
const cliArgs = process.argv.slice(2);
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
const externalPlugins = await loadExternalPlugins();
|
|
21
|
+
let cliLaunchRequest = null;
|
|
22
|
+
if (cliArgs.length > 0) {
|
|
23
|
+
const dispatchResult = await dispatchCli(cliArgs, { externalPlugins });
|
|
24
|
+
if (dispatchResult.kind === "handled") return;
|
|
25
|
+
if (dispatchResult.kind === "launch-ui") {
|
|
26
|
+
cliLaunchRequest = dispatchResult.request;
|
|
27
|
+
}
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
// Determine data directory
|
|
@@ -40,9 +43,6 @@ async function main() {
|
|
|
40
43
|
// Load or create config
|
|
41
44
|
const config = await initDataDir(dataDir);
|
|
42
45
|
|
|
43
|
-
// Load external plugins from ~/.gloomberb/plugins/
|
|
44
|
-
const externalPlugins = await loadExternalPlugins();
|
|
45
|
-
|
|
46
46
|
// Create renderer
|
|
47
47
|
const renderer = await createCliRenderer({
|
|
48
48
|
exitOnCtrlC: true,
|
|
@@ -56,7 +56,7 @@ async function main() {
|
|
|
56
56
|
config={config}
|
|
57
57
|
renderer={renderer}
|
|
58
58
|
externalPlugins={externalPlugins}
|
|
59
|
-
|
|
59
|
+
cliLaunchRequest={cliLaunchRequest}
|
|
60
60
|
/>
|
|
61
61
|
);
|
|
62
62
|
}
|
|
@@ -80,8 +80,8 @@ describe("MarketDataCoordinator", () => {
|
|
|
80
80
|
const coordinator = new MarketDataCoordinator(provider);
|
|
81
81
|
const request = {
|
|
82
82
|
instrument: { symbol: "AAPL", exchange: "NASDAQ" },
|
|
83
|
-
|
|
84
|
-
granularity: "
|
|
83
|
+
bufferRange: "1Y" as const,
|
|
84
|
+
granularity: "range" as const,
|
|
85
85
|
};
|
|
86
86
|
|
|
87
87
|
const first = await coordinator.loadChart(request);
|
|
@@ -103,8 +103,8 @@ describe("MarketDataCoordinator", () => {
|
|
|
103
103
|
const coordinator = new MarketDataCoordinator(provider);
|
|
104
104
|
const request = {
|
|
105
105
|
instrument: { symbol: "AAPL", exchange: "NASDAQ" },
|
|
106
|
-
|
|
107
|
-
granularity: "
|
|
106
|
+
bufferRange: "1Y" as const,
|
|
107
|
+
granularity: "range" as const,
|
|
108
108
|
};
|
|
109
109
|
|
|
110
110
|
const entry = await coordinator.loadChart(request);
|
|
@@ -142,6 +142,30 @@ describe("MarketDataCoordinator", () => {
|
|
|
142
142
|
expect(coordinator.getQuoteEntry(instrument).data?.price).toBe(412.5);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
it("routes manual-resolution chart requests through the resolution-aware provider path", async () => {
|
|
146
|
+
let requested: { range: string; resolution: string } | null = null;
|
|
147
|
+
const provider = createProvider({
|
|
148
|
+
getPriceHistoryForResolution: async (_symbol, _exchange, range, resolution) => {
|
|
149
|
+
requested = { range, resolution };
|
|
150
|
+
return [
|
|
151
|
+
{ date: new Date("2024-01-01"), close: 100 },
|
|
152
|
+
{ date: new Date("2024-01-02"), close: 101 },
|
|
153
|
+
];
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
157
|
+
|
|
158
|
+
const entry = await coordinator.loadChart({
|
|
159
|
+
instrument: { symbol: "AAPL", exchange: "NASDAQ" },
|
|
160
|
+
bufferRange: "1Y",
|
|
161
|
+
granularity: "resolution",
|
|
162
|
+
resolution: "1d",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(requested).toEqual({ range: "1Y", resolution: "1d" });
|
|
166
|
+
expect(entry.data?.length).toBe(2);
|
|
167
|
+
});
|
|
168
|
+
|
|
145
169
|
it("projects live quote updates into premarket display fields when the stream lacks explicit ext-hours fields", async () => {
|
|
146
170
|
let streamed: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
|
|
147
171
|
const provider = createProvider({
|
|
@@ -192,6 +216,7 @@ describe("MarketDataCoordinator", () => {
|
|
|
192
216
|
change: 2.5,
|
|
193
217
|
changePercent: 2.48,
|
|
194
218
|
lastUpdated: Date.now(),
|
|
219
|
+
dataSource: "live",
|
|
195
220
|
},
|
|
196
221
|
);
|
|
197
222
|
|
|
@@ -287,6 +312,69 @@ describe("MarketDataCoordinator", () => {
|
|
|
287
312
|
expect(coordinator.getTickerFinancialsSync(instrument)?.quote?.price).toBe(0.245);
|
|
288
313
|
});
|
|
289
314
|
|
|
315
|
+
it("keeps the fresh snapshot quote when a stale cloud stream update arrives later", async () => {
|
|
316
|
+
const fixedNow = Date.parse("2026-04-08T10:30:00Z");
|
|
317
|
+
const realDateNow = Date.now;
|
|
318
|
+
Date.now = () => fixedNow;
|
|
319
|
+
try {
|
|
320
|
+
let streamed: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
|
|
321
|
+
const provider = createProvider({
|
|
322
|
+
getTickerFinancials: async () => ({
|
|
323
|
+
quote: {
|
|
324
|
+
symbol: "HY9H",
|
|
325
|
+
providerId: "yahoo",
|
|
326
|
+
dataSource: "yahoo",
|
|
327
|
+
price: 598,
|
|
328
|
+
currency: "EUR",
|
|
329
|
+
change: 6,
|
|
330
|
+
changePercent: 1.01,
|
|
331
|
+
lastUpdated: Date.parse("2026-04-08T10:25:00Z"),
|
|
332
|
+
listingExchangeName: "FWB2",
|
|
333
|
+
marketState: "REGULAR",
|
|
334
|
+
sessionConfidence: "derived",
|
|
335
|
+
},
|
|
336
|
+
annualStatements: [],
|
|
337
|
+
quarterlyStatements: [],
|
|
338
|
+
priceHistory: [],
|
|
339
|
+
}),
|
|
340
|
+
subscribeQuotes: (_targets, onQuote) => {
|
|
341
|
+
streamed = onQuote as typeof streamed;
|
|
342
|
+
return () => {};
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
346
|
+
const instrument = { symbol: "HY9H", exchange: "FWB2" };
|
|
347
|
+
|
|
348
|
+
await coordinator.loadSnapshot(instrument);
|
|
349
|
+
coordinator.subscribeQuotes([{ instrument }]);
|
|
350
|
+
const onStreamed = streamed;
|
|
351
|
+
if (!onStreamed) throw new Error("expected streaming callback");
|
|
352
|
+
onStreamed(
|
|
353
|
+
{ symbol: "HY9H", exchange: "FWB2" },
|
|
354
|
+
{
|
|
355
|
+
symbol: "HY9H",
|
|
356
|
+
providerId: "gloomberb-cloud",
|
|
357
|
+
dataSource: "delayed",
|
|
358
|
+
price: 528,
|
|
359
|
+
currency: "EUR",
|
|
360
|
+
change: 4,
|
|
361
|
+
changePercent: 0.76,
|
|
362
|
+
lastUpdated: Date.parse("2026-04-07T17:55:00Z"),
|
|
363
|
+
listingExchangeName: "FWB2",
|
|
364
|
+
marketState: "REGULAR",
|
|
365
|
+
sessionConfidence: "explicit",
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
expect(coordinator.getQuoteEntry(instrument).data).toBeNull();
|
|
370
|
+
expect(coordinator.getQuoteEntry(instrument).lastGoodData?.price).toBe(598);
|
|
371
|
+
expect(coordinator.getTickerFinancialsSync(instrument)?.quote?.price).toBe(598);
|
|
372
|
+
expect(coordinator.getTickerFinancialsSync(instrument)?.quote?.providerId).toBe("yahoo");
|
|
373
|
+
} finally {
|
|
374
|
+
Date.now = realDateNow;
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
290
378
|
it("hydrates ticker financials synchronously from primed cached data", () => {
|
|
291
379
|
const coordinator = new MarketDataCoordinator(createProvider());
|
|
292
380
|
const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "./selectors";
|
|
24
24
|
import { traceMarketData } from "./trace";
|
|
25
25
|
import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
|
|
26
|
+
import { hasFreshQuoteForCurrentSession, isQuoteStaleForCurrentSession } from "../utils/quote-freshness";
|
|
26
27
|
|
|
27
28
|
const EMPTY_MESSAGE = "No data available";
|
|
28
29
|
const EXPECTED_EMPTY = /no data|not found|delisted|unavailable|unsupported/i;
|
|
@@ -30,8 +31,8 @@ const EXPECTED_EMPTY = /no data|not found|delisted|unavailable|unsupported/i;
|
|
|
30
31
|
function createBaselineChartRequest(instrument: InstrumentRef): ChartRequest {
|
|
31
32
|
return {
|
|
32
33
|
instrument,
|
|
33
|
-
|
|
34
|
-
granularity: "
|
|
34
|
+
bufferRange: "5Y",
|
|
35
|
+
granularity: "range",
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -92,6 +93,21 @@ function readyEntry<T>(
|
|
|
92
93
|
};
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
function readyQuoteEntry(
|
|
97
|
+
current: QueryEntry<Quote>,
|
|
98
|
+
quote: Quote,
|
|
99
|
+
source: string,
|
|
100
|
+
attempts: ProviderAttempt[],
|
|
101
|
+
): QueryEntry<Quote> {
|
|
102
|
+
if (
|
|
103
|
+
isQuoteStaleForCurrentSession(quote)
|
|
104
|
+
&& hasFreshQuoteForCurrentSession([current.data, current.lastGoodData])
|
|
105
|
+
) {
|
|
106
|
+
return readyEntry(current, null, current.source ?? source, attempts, { keepLastGoodOnEmpty: true });
|
|
107
|
+
}
|
|
108
|
+
return readyEntry(current, quote, source, attempts, { keepLastGoodOnEmpty: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
95
111
|
function errorEntry<T>(current: QueryEntry<T>, attempt: ProviderAttempt): QueryEntry<T> {
|
|
96
112
|
return {
|
|
97
113
|
...current,
|
|
@@ -349,7 +365,7 @@ export class MarketDataCoordinator {
|
|
|
349
365
|
);
|
|
350
366
|
const source = quote.providerId ?? this.dataProvider.id;
|
|
351
367
|
const attempts = [createAttempt(source, startedAt, "success")];
|
|
352
|
-
return this.quoteStore.update(key, (current) =>
|
|
368
|
+
return this.quoteStore.update(key, (current) => readyQuoteEntry(current, quote, source, attempts));
|
|
353
369
|
} catch (error) {
|
|
354
370
|
const classified = classifyError(error);
|
|
355
371
|
const attempt = createAttempt(this.dataProvider.id, startedAt, EXPECTED_EMPTY.test(classified.message) ? "empty" : "fatal_error", classified.reasonCode, classified.message);
|
|
@@ -381,10 +397,21 @@ export class MarketDataCoordinator {
|
|
|
381
397
|
cacheMode: options.forceRefresh ? "refresh" : "default",
|
|
382
398
|
},
|
|
383
399
|
)
|
|
400
|
+
: request.granularity === "resolution" && request.resolution && this.dataProvider.getPriceHistoryForResolution
|
|
401
|
+
? await this.dataProvider.getPriceHistoryForResolution(
|
|
402
|
+
request.instrument.symbol,
|
|
403
|
+
request.instrument.exchange ?? "",
|
|
404
|
+
request.bufferRange,
|
|
405
|
+
request.resolution,
|
|
406
|
+
{
|
|
407
|
+
...toMarketDataContext(request.instrument),
|
|
408
|
+
cacheMode: options.forceRefresh ? "refresh" : "default",
|
|
409
|
+
},
|
|
410
|
+
)
|
|
384
411
|
: await this.dataProvider.getPriceHistory(
|
|
385
412
|
request.instrument.symbol,
|
|
386
413
|
request.instrument.exchange ?? "",
|
|
387
|
-
request.
|
|
414
|
+
request.bufferRange,
|
|
388
415
|
{
|
|
389
416
|
...toMarketDataContext(request.instrument),
|
|
390
417
|
cacheMode: options.forceRefresh ? "refresh" : "default",
|
|
@@ -555,7 +582,7 @@ export class MarketDataCoordinator {
|
|
|
555
582
|
const key = buildQuoteKey(instrument);
|
|
556
583
|
const current = this.quoteStore.get(key);
|
|
557
584
|
const attempts = [createAttempt(quote.providerId ?? this.dataProvider.id, Date.now(), "success")];
|
|
558
|
-
this.quoteStore.set(key,
|
|
585
|
+
this.quoteStore.set(key, readyQuoteEntry(current, quote, quote.providerId ?? this.dataProvider.id, attempts));
|
|
559
586
|
});
|
|
560
587
|
}
|
|
561
588
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, useState } from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import type { TickerFinancials } from "../types/financials";
|
|
5
|
+
import type { TickerRecord } from "../types/ticker";
|
|
6
|
+
import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "./coordinator";
|
|
7
|
+
import { useFxRatesMap, useTickerFinancialsMap } from "./hooks";
|
|
8
|
+
|
|
9
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
10
|
+
let bumpHarness: (() => void) | null = null;
|
|
11
|
+
let latestFxRates: Map<string, number> | null = null;
|
|
12
|
+
let latestFinancialsMap: Map<string, TickerFinancials> | null = null;
|
|
13
|
+
|
|
14
|
+
const readyUsdEntry = {
|
|
15
|
+
phase: "ready" as const,
|
|
16
|
+
data: 1,
|
|
17
|
+
lastGoodData: 1,
|
|
18
|
+
source: "test",
|
|
19
|
+
fetchedAt: 1,
|
|
20
|
+
staleAt: null,
|
|
21
|
+
error: null,
|
|
22
|
+
attempts: [],
|
|
23
|
+
};
|
|
24
|
+
const readyEurEntry = {
|
|
25
|
+
phase: "ready" as const,
|
|
26
|
+
data: 1.08,
|
|
27
|
+
lastGoodData: 1.08,
|
|
28
|
+
source: "test",
|
|
29
|
+
fetchedAt: 1,
|
|
30
|
+
staleAt: null,
|
|
31
|
+
error: null,
|
|
32
|
+
attempts: [],
|
|
33
|
+
};
|
|
34
|
+
const sampleFinancials: TickerFinancials = {
|
|
35
|
+
annualStatements: [],
|
|
36
|
+
quarterlyStatements: [],
|
|
37
|
+
priceHistory: [],
|
|
38
|
+
quote: {
|
|
39
|
+
symbol: "SAP",
|
|
40
|
+
price: 250,
|
|
41
|
+
currency: "EUR",
|
|
42
|
+
change: 2,
|
|
43
|
+
changePercent: 0.8,
|
|
44
|
+
lastUpdated: 1,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const tickers: TickerRecord[] = [
|
|
48
|
+
{
|
|
49
|
+
metadata: {
|
|
50
|
+
ticker: "SAP",
|
|
51
|
+
exchange: "XETRA",
|
|
52
|
+
currency: "EUR",
|
|
53
|
+
name: "SAP",
|
|
54
|
+
portfolios: [],
|
|
55
|
+
watchlists: [],
|
|
56
|
+
positions: [],
|
|
57
|
+
custom: {},
|
|
58
|
+
tags: [],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function HooksHarness() {
|
|
64
|
+
const [tick, setTick] = useState(0);
|
|
65
|
+
bumpHarness = () => setTick((current) => current + 1);
|
|
66
|
+
|
|
67
|
+
latestFxRates = useFxRatesMap(["USD", "EUR"]);
|
|
68
|
+
latestFinancialsMap = useTickerFinancialsMap(tickers);
|
|
69
|
+
|
|
70
|
+
return <text>{String(tick)}</text>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
afterEach(async () => {
|
|
74
|
+
if (testSetup) {
|
|
75
|
+
await act(async () => {
|
|
76
|
+
testSetup!.renderer.destroy();
|
|
77
|
+
});
|
|
78
|
+
testSetup = undefined;
|
|
79
|
+
}
|
|
80
|
+
bumpHarness = null;
|
|
81
|
+
latestFxRates = null;
|
|
82
|
+
latestFinancialsMap = null;
|
|
83
|
+
setSharedMarketDataCoordinator(null);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("market-data hooks", () => {
|
|
87
|
+
test("preserve derived map instances across unrelated rerenders", async () => {
|
|
88
|
+
const coordinator = {
|
|
89
|
+
subscribe: () => () => {},
|
|
90
|
+
getVersion: () => 1,
|
|
91
|
+
getFxEntry: (currency: string) => (currency === "EUR" ? readyEurEntry : readyUsdEntry),
|
|
92
|
+
loadFxRate: async () => {},
|
|
93
|
+
getTickerFinancialsSync: () => sampleFinancials,
|
|
94
|
+
};
|
|
95
|
+
setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
|
|
96
|
+
|
|
97
|
+
testSetup = await testRender(<HooksHarness />, {
|
|
98
|
+
width: 20,
|
|
99
|
+
height: 1,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await act(async () => {
|
|
103
|
+
await testSetup!.renderOnce();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const initialFxRates = latestFxRates;
|
|
107
|
+
const initialFinancialsMap = latestFinancialsMap;
|
|
108
|
+
|
|
109
|
+
await act(async () => {
|
|
110
|
+
bumpHarness?.();
|
|
111
|
+
await Promise.resolve();
|
|
112
|
+
});
|
|
113
|
+
await act(async () => {
|
|
114
|
+
await testSetup!.renderOnce();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(latestFxRates).toBe(initialFxRates);
|
|
118
|
+
expect(latestFinancialsMap).toBe(initialFinancialsMap);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
resolveEntryValue,
|
|
10
10
|
} from "./coordinator";
|
|
11
11
|
import type { QueryEntry } from "./result-types";
|
|
12
|
+
import { buildChartKey } from "./selectors";
|
|
12
13
|
|
|
13
14
|
function useCoordinatorVersion(): number {
|
|
14
15
|
const coordinator = getSharedMarketDataCoordinator();
|
|
@@ -33,6 +34,21 @@ function stableCurrencyList(currencies: Array<string | null | undefined>): strin
|
|
|
33
34
|
)].sort((left, right) => left.localeCompare(right));
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
function buildTickerFinancialsMapKey(tickers: TickerRecord[]): string {
|
|
38
|
+
return tickers.map((ticker) => {
|
|
39
|
+
const instrument = ticker.metadata.broker_contracts?.[0];
|
|
40
|
+
return [
|
|
41
|
+
ticker.metadata.ticker,
|
|
42
|
+
ticker.metadata.exchange ?? "",
|
|
43
|
+
instrument?.brokerId ?? "",
|
|
44
|
+
instrument?.brokerInstanceId ?? "",
|
|
45
|
+
instrument?.conId ?? "",
|
|
46
|
+
instrument?.localSymbol ?? "",
|
|
47
|
+
instrument?.symbol ?? "",
|
|
48
|
+
].join("|");
|
|
49
|
+
}).join("::");
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
export function useTickerInstrument(symbol: string | null | undefined, ticker: TickerRecord | null | undefined): InstrumentRef | null {
|
|
37
53
|
return useMemo(() => instrumentFromTicker(ticker, symbol ?? null), [symbol, ticker]);
|
|
38
54
|
}
|
|
@@ -54,7 +70,12 @@ export function useTickerFinancials(symbol: string | null | undefined, ticker: T
|
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
export function useTickerFinancialsMap(tickers: TickerRecord[]): Map<string, TickerFinancials> {
|
|
57
|
-
|
|
73
|
+
const coordinator = getSharedMarketDataCoordinator();
|
|
74
|
+
const version = useCoordinatorVersion();
|
|
75
|
+
const tickerKey = buildTickerFinancialsMapKey(tickers);
|
|
76
|
+
|
|
77
|
+
return useMemo(() => {
|
|
78
|
+
if (!coordinator) return new Map<string, TickerFinancials>();
|
|
58
79
|
const result = new Map<string, TickerFinancials>();
|
|
59
80
|
for (const ticker of tickers) {
|
|
60
81
|
const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
|
|
@@ -65,7 +86,7 @@ export function useTickerFinancialsMap(tickers: TickerRecord[]): Map<string, Tic
|
|
|
65
86
|
}
|
|
66
87
|
}
|
|
67
88
|
return result;
|
|
68
|
-
},
|
|
89
|
+
}, [coordinator, tickerKey, version]);
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
export function useQuoteEntry(symbol: string | null | undefined, ticker: TickerRecord | null | undefined): QueryEntry<Quote> | null {
|
|
@@ -85,15 +106,7 @@ export function useQuoteEntry(symbol: string | null | undefined, ticker: TickerR
|
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
export function useChartQuery(request: ChartRequest | null | undefined): QueryEntry<PricePoint[]> | null {
|
|
88
|
-
const key = request ?
|
|
89
|
-
request.instrument.symbol,
|
|
90
|
-
request.instrument.exchange ?? "",
|
|
91
|
-
request.range,
|
|
92
|
-
request.granularity ?? "daily",
|
|
93
|
-
request.startDate?.toISOString() ?? "",
|
|
94
|
-
request.endDate?.toISOString() ?? "",
|
|
95
|
-
request.barSize ?? "",
|
|
96
|
-
].join("|") : null;
|
|
109
|
+
const key = request ? buildChartKey(request) : null;
|
|
97
110
|
const entry = useCoordinatorSelector(
|
|
98
111
|
(coordinator) => (request ? coordinator.getChartEntry(request) : null),
|
|
99
112
|
null,
|
|
@@ -108,6 +121,23 @@ export function useChartQuery(request: ChartRequest | null | undefined): QueryEn
|
|
|
108
121
|
return entry;
|
|
109
122
|
}
|
|
110
123
|
|
|
124
|
+
export function useChartQueries(requests: readonly ChartRequest[]): Map<string, QueryEntry<PricePoint[]>> {
|
|
125
|
+
const requestKey = requests.map((request) => buildChartKey(request)).join(",");
|
|
126
|
+
const entries = useCoordinatorSelector((coordinator) => (
|
|
127
|
+
requests.map((request) => [buildChartKey(request), coordinator.getChartEntry(request)] as const)
|
|
128
|
+
), [] as Array<readonly [string, QueryEntry<PricePoint[]>]>);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const coordinator = getSharedMarketDataCoordinator();
|
|
132
|
+
if (!coordinator) return;
|
|
133
|
+
for (const request of requests) {
|
|
134
|
+
void coordinator.loadChart(request);
|
|
135
|
+
}
|
|
136
|
+
}, [requestKey]);
|
|
137
|
+
|
|
138
|
+
return useMemo(() => new Map(entries), [entries]);
|
|
139
|
+
}
|
|
140
|
+
|
|
111
141
|
export function useNewsQuery(request: NewsRequest | null | undefined): QueryEntry<NewsItem[]> | null {
|
|
112
142
|
const key = request ? `${request.instrument.symbol}|${request.instrument.exchange ?? ""}|${request.count ?? 50}` : null;
|
|
113
143
|
const entry = useCoordinatorSelector(
|
|
@@ -203,10 +233,17 @@ export function useFxRate(currency: string | null | undefined): QueryEntry<numbe
|
|
|
203
233
|
}
|
|
204
234
|
|
|
205
235
|
export function useFxRatesMap(currencies: Array<string | null | undefined>): Map<string, number> {
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
236
|
+
const coordinator = getSharedMarketDataCoordinator();
|
|
237
|
+
const version = useCoordinatorVersion();
|
|
238
|
+
const normalizedCurrencyKey = stableCurrencyList(currencies).join("|");
|
|
239
|
+
const normalizedCurrencies = useMemo(
|
|
240
|
+
() => (normalizedCurrencyKey ? normalizedCurrencyKey.split("|") : []),
|
|
241
|
+
[normalizedCurrencyKey],
|
|
242
|
+
);
|
|
243
|
+
const entries = useMemo(() => {
|
|
244
|
+
if (!coordinator) return [] as Array<readonly [string, QueryEntry<number>]>;
|
|
245
|
+
return normalizedCurrencies.map((currency) => [currency, coordinator.getFxEntry(currency)] as const);
|
|
246
|
+
}, [coordinator, normalizedCurrencies, version]);
|
|
210
247
|
|
|
211
248
|
useEffect(() => {
|
|
212
249
|
const coordinator = getSharedMarketDataCoordinator();
|
|
@@ -214,7 +251,7 @@ export function useFxRatesMap(currencies: Array<string | null | undefined>): Map
|
|
|
214
251
|
for (const currency of normalizedCurrencies) {
|
|
215
252
|
void coordinator.loadFxRate(currency);
|
|
216
253
|
}
|
|
217
|
-
}, [
|
|
254
|
+
}, [normalizedCurrencyKey]);
|
|
218
255
|
|
|
219
256
|
return useMemo(() => {
|
|
220
257
|
const rates = new Map<string, number>();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TimeRange } from "../components/chart/chart-types";
|
|
2
|
+
import type { ManualChartResolution } from "../components/chart/chart-resolution";
|
|
2
3
|
import type { QuoteSubscriptionTarget } from "../types/data-provider";
|
|
3
4
|
import type { BrokerContractRef } from "../types/instrument";
|
|
4
5
|
import type { TickerRecord } from "../types/ticker";
|
|
@@ -11,12 +12,13 @@ export interface InstrumentRef {
|
|
|
11
12
|
instrument?: BrokerContractRef | null;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export type ChartGranularity = "
|
|
15
|
+
export type ChartGranularity = "range" | "detail" | "resolution";
|
|
15
16
|
|
|
16
17
|
export interface ChartRequest {
|
|
17
18
|
instrument: InstrumentRef;
|
|
18
|
-
|
|
19
|
+
bufferRange: TimeRange;
|
|
19
20
|
granularity?: ChartGranularity;
|
|
21
|
+
resolution?: ManualChartResolution;
|
|
20
22
|
startDate?: Date;
|
|
21
23
|
endDate?: Date;
|
|
22
24
|
barSize?: string;
|