gloomberb 0.7.0 → 0.7.2
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/package.json +1 -1
- package/src/app.tsx +149 -51
- package/src/capabilities/factories.ts +3 -0
- package/src/components/chart/chart-renderer.ts +24 -12
- package/src/components/chart/chart.test.ts +43 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +15 -12
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +2 -0
- package/src/components/chart/stock-chart.tsx +31 -20
- package/src/components/chart/time-axis-label.tsx +78 -1
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +114 -31
- package/src/components/command-bar/command-bar.tsx +587 -335
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +1 -2
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -1
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +5 -2
- package/src/components/layout/header.tsx +2 -0
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +198 -0
- package/src/components/layout/shell.tsx +33 -2
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/speedometer-gauge.tsx +29 -25
- package/src/components/table-view-shared.tsx +85 -3
- package/src/components/ticker-list-table-view.tsx +16 -2
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/data-table.tsx +12 -9
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/table-layout.test.ts +27 -1
- package/src/components/ui/table-layout.ts +39 -1
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +68 -1
- package/src/core/state/app-state.ts +4 -14
- package/src/market-data/coordinator.test.ts +75 -0
- package/src/market-data/coordinator.ts +218 -48
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -0
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/chat-controller.test.ts +83 -1
- package/src/plugins/builtin/chat-controller.ts +491 -243
- package/src/plugins/builtin/chat.test.tsx +425 -7
- package/src/plugins/builtin/chat.tsx +1109 -211
- package/src/plugins/builtin/cloud-tweets.tsx +1 -0
- package/src/plugins/builtin/fear-greed/index.tsx +148 -39
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +54 -14
- package/src/plugins/builtin/market-movers/index.tsx +12 -18
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/notes.tsx +1 -0
- package/src/plugins/builtin/options.tsx +2 -16
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +159 -12
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/plugin-runtime.test.tsx +34 -0
- package/src/plugins/plugin-runtime.tsx +7 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/registry.ts +3 -3
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +1 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +111 -96
- package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
- package/src/renderers/electrobun/view/input-host.tsx +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +6 -8
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +101 -5
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -11
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +20 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.ts +13 -10
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +64 -3
- package/src/theme/theme-context.tsx +26 -0
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -1
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -1
- package/src/ui/host.tsx +14 -2
- package/src/ui/index.tsx +1 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
|
@@ -886,13 +886,29 @@ const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & {
|
|
|
886
886
|
);
|
|
887
887
|
const horizontalScrollBarVisibleRef = useRef(horizontalScrollBarVisible);
|
|
888
888
|
const verticalScrollBarVisibleRef = useRef(verticalScrollBarVisible);
|
|
889
|
+
const horizontalScrollChangeHandlersRef = useRef(new Set<() => void>());
|
|
890
|
+
const verticalScrollChangeHandlersRef = useRef(new Set<() => void>());
|
|
889
891
|
const scrollFrameRef = useRef<number | null>(null);
|
|
890
892
|
const lastWheelAtRef = useRef(0);
|
|
891
893
|
const [scrollbarActive, markScrollbarActive] = useScrollbarActivity();
|
|
892
894
|
|
|
893
|
-
const getElement = () => elementRef.current;
|
|
895
|
+
const getElement = useCallback(() => elementRef.current, []);
|
|
894
896
|
const toCellY = (pixels: number) => Math.max(0, Math.round(pixels / WEB_CELL_HEIGHT));
|
|
895
897
|
const toCellX = (pixels: number) => Math.max(0, Math.round(pixels / WEB_CELL_WIDTH));
|
|
898
|
+
const emitHorizontalScrollChange = useCallback(() => {
|
|
899
|
+
for (const handler of horizontalScrollChangeHandlersRef.current) {
|
|
900
|
+
handler();
|
|
901
|
+
}
|
|
902
|
+
}, []);
|
|
903
|
+
const emitVerticalScrollChange = useCallback(() => {
|
|
904
|
+
for (const handler of verticalScrollChangeHandlersRef.current) {
|
|
905
|
+
handler();
|
|
906
|
+
}
|
|
907
|
+
}, []);
|
|
908
|
+
const emitScrollPositionChanges = useCallback(() => {
|
|
909
|
+
emitVerticalScrollChange();
|
|
910
|
+
emitHorizontalScrollChange();
|
|
911
|
+
}, [emitHorizontalScrollChange, emitVerticalScrollChange]);
|
|
896
912
|
const horizontalScrollBar = useMemo(() => ({
|
|
897
913
|
get visible() {
|
|
898
914
|
return horizontalScrollBarVisibleRef.current;
|
|
@@ -902,6 +918,12 @@ const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & {
|
|
|
902
918
|
horizontalScrollBarVisibleRef.current = normalized;
|
|
903
919
|
setHorizontalScrollBarVisible(normalized);
|
|
904
920
|
},
|
|
921
|
+
on(event: "change", handler: () => void) {
|
|
922
|
+
if (event === "change") horizontalScrollChangeHandlersRef.current.add(handler);
|
|
923
|
+
},
|
|
924
|
+
off(event: "change", handler: () => void) {
|
|
925
|
+
if (event === "change") horizontalScrollChangeHandlersRef.current.delete(handler);
|
|
926
|
+
},
|
|
905
927
|
}), []);
|
|
906
928
|
const verticalScrollBar = useMemo(() => ({
|
|
907
929
|
get visible() {
|
|
@@ -912,6 +934,12 @@ const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & {
|
|
|
912
934
|
verticalScrollBarVisibleRef.current = normalized;
|
|
913
935
|
setVerticalScrollBarVisible(normalized);
|
|
914
936
|
},
|
|
937
|
+
on(event: "change", handler: () => void) {
|
|
938
|
+
if (event === "change") verticalScrollChangeHandlersRef.current.add(handler);
|
|
939
|
+
},
|
|
940
|
+
off(event: "change", handler: () => void) {
|
|
941
|
+
if (event === "change") verticalScrollChangeHandlersRef.current.delete(handler);
|
|
942
|
+
},
|
|
915
943
|
}), []);
|
|
916
944
|
|
|
917
945
|
useEffect(() => {
|
|
@@ -938,14 +966,40 @@ const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & {
|
|
|
938
966
|
},
|
|
939
967
|
set scrollTop(value: number) {
|
|
940
968
|
const element = getElement();
|
|
941
|
-
if (element)
|
|
969
|
+
if (element) {
|
|
970
|
+
element.scrollTop = Math.max(0, value) * WEB_CELL_HEIGHT;
|
|
971
|
+
emitVerticalScrollChange();
|
|
972
|
+
}
|
|
973
|
+
},
|
|
974
|
+
get scrollTopPx() {
|
|
975
|
+
return Math.max(0, getElement()?.scrollTop ?? 0);
|
|
976
|
+
},
|
|
977
|
+
set scrollTopPx(value: number) {
|
|
978
|
+
const element = getElement();
|
|
979
|
+
if (element) {
|
|
980
|
+
element.scrollTop = Math.max(0, value);
|
|
981
|
+
emitVerticalScrollChange();
|
|
982
|
+
}
|
|
942
983
|
},
|
|
943
984
|
get scrollLeft() {
|
|
944
985
|
return toCellX(getElement()?.scrollLeft ?? 0);
|
|
945
986
|
},
|
|
946
987
|
set scrollLeft(value: number) {
|
|
947
988
|
const element = getElement();
|
|
948
|
-
if (element)
|
|
989
|
+
if (element) {
|
|
990
|
+
element.scrollLeft = Math.max(0, value) * WEB_CELL_WIDTH;
|
|
991
|
+
emitHorizontalScrollChange();
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
get scrollLeftPx() {
|
|
995
|
+
return Math.max(0, getElement()?.scrollLeft ?? 0);
|
|
996
|
+
},
|
|
997
|
+
set scrollLeftPx(value: number) {
|
|
998
|
+
const element = getElement();
|
|
999
|
+
if (element) {
|
|
1000
|
+
element.scrollLeft = Math.max(0, value);
|
|
1001
|
+
emitHorizontalScrollChange();
|
|
1002
|
+
}
|
|
949
1003
|
},
|
|
950
1004
|
get scrollHeight() {
|
|
951
1005
|
return toCellY(getElement()?.scrollHeight ?? 0);
|
|
@@ -953,6 +1007,12 @@ const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & {
|
|
|
953
1007
|
get scrollWidth() {
|
|
954
1008
|
return toCellX(getElement()?.scrollWidth ?? 0);
|
|
955
1009
|
},
|
|
1010
|
+
get scrollHeightPx() {
|
|
1011
|
+
return Math.max(0, getElement()?.scrollHeight ?? 0);
|
|
1012
|
+
},
|
|
1013
|
+
get scrollWidthPx() {
|
|
1014
|
+
return Math.max(0, getElement()?.scrollWidth ?? 0);
|
|
1015
|
+
},
|
|
956
1016
|
get viewport() {
|
|
957
1017
|
const element = getElement();
|
|
958
1018
|
return {
|
|
@@ -960,6 +1020,13 @@ const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & {
|
|
|
960
1020
|
height: toCellY(element?.clientHeight ?? 0),
|
|
961
1021
|
};
|
|
962
1022
|
},
|
|
1023
|
+
get viewportPx() {
|
|
1024
|
+
const element = getElement();
|
|
1025
|
+
return {
|
|
1026
|
+
width: Math.max(0, element?.clientWidth ?? 0),
|
|
1027
|
+
height: Math.max(0, element?.clientHeight ?? 0),
|
|
1028
|
+
};
|
|
1029
|
+
},
|
|
963
1030
|
getBoundingClientRect() {
|
|
964
1031
|
const rect = getElement()?.getBoundingClientRect();
|
|
965
1032
|
return {
|
|
@@ -976,8 +1043,10 @@ const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & {
|
|
|
976
1043
|
if (!element) return;
|
|
977
1044
|
if (typeof target === "number") {
|
|
978
1045
|
element.scrollTop = Math.max(0, target) * WEB_CELL_HEIGHT;
|
|
1046
|
+
emitVerticalScrollChange();
|
|
979
1047
|
if (typeof y === "number") {
|
|
980
1048
|
element.scrollLeft = Math.max(0, y) * WEB_CELL_WIDTH;
|
|
1049
|
+
emitHorizontalScrollChange();
|
|
981
1050
|
}
|
|
982
1051
|
return;
|
|
983
1052
|
}
|
|
@@ -985,13 +1054,40 @@ const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & {
|
|
|
985
1054
|
left: Math.max(0, target.x ?? toCellX(element.scrollLeft)) * WEB_CELL_WIDTH,
|
|
986
1055
|
top: Math.max(0, target.y ?? toCellY(element.scrollTop)) * WEB_CELL_HEIGHT,
|
|
987
1056
|
});
|
|
1057
|
+
emitScrollPositionChanges();
|
|
1058
|
+
},
|
|
1059
|
+
scrollToPixels(target: number | { x?: number; y?: number }, y?: number) {
|
|
1060
|
+
const element = getElement();
|
|
1061
|
+
if (!element) return;
|
|
1062
|
+
if (typeof target === "number") {
|
|
1063
|
+
element.scrollTop = Math.max(0, target);
|
|
1064
|
+
emitVerticalScrollChange();
|
|
1065
|
+
if (typeof y === "number") {
|
|
1066
|
+
element.scrollLeft = Math.max(0, y);
|
|
1067
|
+
emitHorizontalScrollChange();
|
|
1068
|
+
}
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
element.scrollTo({
|
|
1072
|
+
left: Math.max(0, target.x ?? element.scrollLeft),
|
|
1073
|
+
top: Math.max(0, target.y ?? element.scrollTop),
|
|
1074
|
+
});
|
|
1075
|
+
emitScrollPositionChanges();
|
|
988
1076
|
},
|
|
989
|
-
}), [
|
|
1077
|
+
}), [
|
|
1078
|
+
emitHorizontalScrollChange,
|
|
1079
|
+
emitScrollPositionChanges,
|
|
1080
|
+
emitVerticalScrollChange,
|
|
1081
|
+
getElement,
|
|
1082
|
+
horizontalScrollBar,
|
|
1083
|
+
verticalScrollBar,
|
|
1084
|
+
]);
|
|
990
1085
|
|
|
991
1086
|
const scrollable = props.scrollX === true || props.scrollY === true;
|
|
992
1087
|
const overflowX = props.scrollX === true ? "auto" : "hidden";
|
|
993
1088
|
const overflowY = props.scrollY === true ? "auto" : "hidden";
|
|
994
1089
|
const handleScroll = useCallback(() => {
|
|
1090
|
+
emitScrollPositionChanges();
|
|
995
1091
|
markScrollbarActive();
|
|
996
1092
|
if (typeof props.onMouseScroll !== "function") return;
|
|
997
1093
|
if (Date.now() - lastWheelAtRef.current < 32) return;
|
|
@@ -1000,7 +1096,7 @@ const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & {
|
|
|
1000
1096
|
scrollFrameRef.current = null;
|
|
1001
1097
|
(props.onMouseScroll as () => void)();
|
|
1002
1098
|
});
|
|
1003
|
-
}, [markScrollbarActive, props.onMouseScroll]);
|
|
1099
|
+
}, [emitScrollPositionChanges, markScrollbarActive, props.onMouseScroll]);
|
|
1004
1100
|
const handleWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
|
|
1005
1101
|
markScrollbarActive();
|
|
1006
1102
|
lastWheelAtRef.current = Date.now();
|
|
@@ -15,7 +15,7 @@ export function OpenTuiInputHostProvider({ children }: { children: ReactNode })
|
|
|
15
15
|
const shortcutsRef = useRef<ShortcutEntry[]>([]);
|
|
16
16
|
const dispatchShortcut = (event: KeyEventLike) => {
|
|
17
17
|
const shortcuts = shortcutsRef.current;
|
|
18
|
-
for (const phase of ["normal", "after"] as const) {
|
|
18
|
+
for (const phase of ["before", "normal", "after"] as const) {
|
|
19
19
|
if (phase === "after" && (event.defaultPrevented || event.propagationStopped)) return;
|
|
20
20
|
for (const entry of shortcuts) {
|
|
21
21
|
if (entry.phase !== phase || !entry.enabledRef.current) continue;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { createGloomberbCloudCapabilities, GloomberbCloudProvider } from "./gloomberb-cloud";
|
|
3
3
|
import type { NewsCapability } from "../capabilities";
|
|
4
|
-
import { apiClient, type AuthUser } from "../utils/api-client";
|
|
4
|
+
import { apiClient, type AuthUser, type CloudNewsPayload } from "../utils/api-client";
|
|
5
5
|
|
|
6
6
|
const verifiedUser: AuthUser = {
|
|
7
7
|
id: "user-1",
|
|
@@ -21,8 +21,56 @@ const originalGetCloudHolders = apiClient.getCloudHolders.bind(apiClient);
|
|
|
21
21
|
const originalGetCloudAnalystResearch = apiClient.getCloudAnalystResearch.bind(apiClient);
|
|
22
22
|
const originalGetCloudCorporateActions = apiClient.getCloudCorporateActions.bind(apiClient);
|
|
23
23
|
const originalGetCloudNews = apiClient.getCloudNews.bind(apiClient);
|
|
24
|
+
const originalGetCloudNewsStory = apiClient.getCloudNewsStory.bind(apiClient);
|
|
24
25
|
const originalSubscribeQuotes = apiClient.subscribeQuotes.bind(apiClient);
|
|
25
26
|
|
|
27
|
+
function makeCloudNewsPayload(overrides: Partial<CloudNewsPayload> = {}): CloudNewsPayload {
|
|
28
|
+
return {
|
|
29
|
+
id: "story-1",
|
|
30
|
+
headline: "Apple raises guidance",
|
|
31
|
+
summary: "Apple lifted its outlook after stronger iPhone demand.",
|
|
32
|
+
topic: "guidance",
|
|
33
|
+
topics: ["guidance"],
|
|
34
|
+
category: "guidance",
|
|
35
|
+
sentiment: "positive",
|
|
36
|
+
sectors: ["information_technology"],
|
|
37
|
+
firstPublishedAt: "2026-04-01T10:00:00.000Z",
|
|
38
|
+
lastPublishedAt: "2026-04-01T10:05:00.000Z",
|
|
39
|
+
firstSeenAt: "2026-04-01T10:00:10.000Z",
|
|
40
|
+
lastSeenAt: "2026-04-01T10:05:10.000Z",
|
|
41
|
+
primaryUrl: "https://example.com/aapl-guidance",
|
|
42
|
+
primarySource: "example-wire",
|
|
43
|
+
scores: {
|
|
44
|
+
importance: 91,
|
|
45
|
+
urgency: 74,
|
|
46
|
+
marketImpact: 88,
|
|
47
|
+
novelty: 86,
|
|
48
|
+
confidence: 95,
|
|
49
|
+
},
|
|
50
|
+
flags: {
|
|
51
|
+
breaking: true,
|
|
52
|
+
developing: false,
|
|
53
|
+
stale: false,
|
|
54
|
+
},
|
|
55
|
+
variantCount: 1,
|
|
56
|
+
sourceCount: 1,
|
|
57
|
+
sources: ["example-wire"],
|
|
58
|
+
entities: [],
|
|
59
|
+
tickerLinks: [{
|
|
60
|
+
symbol: "AAPL",
|
|
61
|
+
exchange: "XNAS",
|
|
62
|
+
canonicalTicker: "AAPL:XNAS",
|
|
63
|
+
relationType: "direct",
|
|
64
|
+
displayTier: "primary",
|
|
65
|
+
confidence: 0.98,
|
|
66
|
+
relevanceScore: 95,
|
|
67
|
+
impactScore: 93,
|
|
68
|
+
sentiment: "positive",
|
|
69
|
+
}],
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
26
74
|
afterEach(() => {
|
|
27
75
|
apiClient.ensureVerifiedSession = originalEnsureVerifiedSession;
|
|
28
76
|
apiClient.getCloudHistory = originalGetCloudHistory;
|
|
@@ -31,6 +79,7 @@ afterEach(() => {
|
|
|
31
79
|
apiClient.getCloudAnalystResearch = originalGetCloudAnalystResearch;
|
|
32
80
|
apiClient.getCloudCorporateActions = originalGetCloudCorporateActions;
|
|
33
81
|
apiClient.getCloudNews = originalGetCloudNews;
|
|
82
|
+
apiClient.getCloudNewsStory = originalGetCloudNewsStory;
|
|
34
83
|
apiClient.subscribeQuotes = originalSubscribeQuotes;
|
|
35
84
|
});
|
|
36
85
|
|
|
@@ -496,4 +545,40 @@ describe("GloomberbCloudProvider", () => {
|
|
|
496
545
|
confidence: 93,
|
|
497
546
|
});
|
|
498
547
|
});
|
|
548
|
+
|
|
549
|
+
test("fetches story detail with ordered source items", async () => {
|
|
550
|
+
let requestedStoryId = "";
|
|
551
|
+
apiClient.getCloudNewsStory = async (storyId) => {
|
|
552
|
+
requestedStoryId = storyId;
|
|
553
|
+
return makeCloudNewsPayload({
|
|
554
|
+
id: storyId,
|
|
555
|
+
items: [{
|
|
556
|
+
id: "item-2",
|
|
557
|
+
sourceKey: "wire-b",
|
|
558
|
+
sourceName: "Wire B",
|
|
559
|
+
title: "Follow-up",
|
|
560
|
+
summary: "More details emerged.",
|
|
561
|
+
url: "https://example.com/follow-up",
|
|
562
|
+
publishedAt: "2026-04-01T10:05:00.000Z",
|
|
563
|
+
hasArticleText: true,
|
|
564
|
+
}, {
|
|
565
|
+
id: "item-1",
|
|
566
|
+
sourceKey: "wire-a",
|
|
567
|
+
sourceName: "Wire A",
|
|
568
|
+
title: "Original",
|
|
569
|
+
summary: "The first report.",
|
|
570
|
+
url: "https://example.com/original",
|
|
571
|
+
publishedAt: "2026-04-01T10:00:00.000Z",
|
|
572
|
+
hasArticleText: false,
|
|
573
|
+
}],
|
|
574
|
+
});
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const source = createGloomberbCloudCapabilities().find((capability) => capability.kind === "news") as NewsCapability;
|
|
578
|
+
const story = await source.provider.fetchNewsStory?.("story-1");
|
|
579
|
+
|
|
580
|
+
expect(requestedStoryId).toBe("story-1");
|
|
581
|
+
expect(story?.items?.map((item) => item.id)).toEqual(["item-2", "item-1"]);
|
|
582
|
+
expect(story?.items?.[0]?.publishedAt).toEqual(new Date("2026-04-01T10:05:00.000Z"));
|
|
583
|
+
});
|
|
499
584
|
});
|
|
@@ -5,15 +5,24 @@ import {
|
|
|
5
5
|
type ManualChartResolution,
|
|
6
6
|
} from "../components/chart/chart-resolution";
|
|
7
7
|
import { assetDataProvider, newsProvider, type PluginCapability } from "../capabilities";
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
AssetDataProvider,
|
|
10
|
+
CachedFinancialsTarget,
|
|
11
|
+
MarketDataRequestContext,
|
|
12
|
+
NewsItem,
|
|
13
|
+
QuoteBatchResult,
|
|
14
|
+
QuoteSubscriptionTarget,
|
|
15
|
+
SearchRequestContext,
|
|
16
|
+
SecFilingItem,
|
|
17
|
+
TickerFinancialsBatchResult,
|
|
18
|
+
} from "../types/data-provider";
|
|
9
19
|
import type { AnalystResearchData, CorporateActionsData, HolderData, OptionsChain, PricePoint, Quote, TickerFinancials } from "../types/financials";
|
|
10
20
|
import type { InstrumentSearchResult } from "../types/instrument";
|
|
11
21
|
import {
|
|
12
22
|
apiClient,
|
|
13
23
|
type CloudAnalystResearchPayload,
|
|
14
|
-
type CloudCompanyProfile,
|
|
15
24
|
type CloudCorporateActionsPayload,
|
|
16
|
-
type
|
|
25
|
+
type CloudMarketBatchItem,
|
|
17
26
|
type CloudHoldersPayload,
|
|
18
27
|
type CloudMarketResponse,
|
|
19
28
|
type CloudNewsPayload,
|
|
@@ -103,6 +112,34 @@ function mapPricePoint(point: CloudPricePointPayload, divisor = 1): PricePoint {
|
|
|
103
112
|
};
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
function mapCloudFinancials(financials: TickerFinancials): TickerFinancials {
|
|
116
|
+
const quote = financials.quote ? mapQuote(financials.quote as CloudQuotePayload) : undefined;
|
|
117
|
+
const divisor = quote ? resolveCurrencyUnit(quote.currency).divisor : 1;
|
|
118
|
+
return {
|
|
119
|
+
quote,
|
|
120
|
+
quoteContributions: financials.quoteContributions,
|
|
121
|
+
profile: financials.profile,
|
|
122
|
+
fundamentals: financials.fundamentals,
|
|
123
|
+
annualStatements: financials.annualStatements ?? [],
|
|
124
|
+
quarterlyStatements: financials.quarterlyStatements ?? [],
|
|
125
|
+
priceHistory: (financials.priceHistory ?? []).map((point) =>
|
|
126
|
+
point.date instanceof Date
|
|
127
|
+
? point
|
|
128
|
+
: mapPricePoint(point as unknown as CloudPricePointPayload, divisor)
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function mapBatchError<T>(
|
|
134
|
+
item: CloudMarketBatchItem<T>,
|
|
135
|
+
fallbackMessage: string,
|
|
136
|
+
): Error {
|
|
137
|
+
if (isEmptyCloudStatus(item.status)) {
|
|
138
|
+
return createProviderMiss(item.reasonCode ?? fallbackMessage);
|
|
139
|
+
}
|
|
140
|
+
return new Error(item.reasonCode ?? fallbackMessage);
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
function normalizeStringArray(value: unknown): string[] {
|
|
107
144
|
return Array.isArray(value)
|
|
108
145
|
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
@@ -358,27 +395,45 @@ export class GloomberbCloudProvider implements AssetDataProvider {
|
|
|
358
395
|
async getTickerFinancials(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise<TickerFinancials> {
|
|
359
396
|
await requireVerifiedSession();
|
|
360
397
|
return withCloudFallback(async () => {
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
apiClient.getCloudProfile(ticker, exchange),
|
|
364
|
-
apiClient.getCloudFundamentals(ticker, exchange),
|
|
365
|
-
apiClient.getCloudStatements(ticker, exchange, "both"),
|
|
366
|
-
]);
|
|
367
|
-
const quote = mapQuote(unwrapRequiredCloudResponse(quoteResponse, `Cloud quote is unavailable for ${ticker}`));
|
|
368
|
-
const profile = unwrapOptionalCloudResponse(profileResponse) as CloudCompanyProfile | null;
|
|
369
|
-
const fundamentals = unwrapOptionalCloudResponse(fundamentalsResponse) as CloudFundamentals | null;
|
|
370
|
-
const statements = unwrapOptionalCloudResponse(statementsResponse);
|
|
371
|
-
return {
|
|
372
|
-
quote,
|
|
373
|
-
profile: profile ?? undefined,
|
|
374
|
-
fundamentals: fundamentals ?? undefined,
|
|
375
|
-
annualStatements: statements?.annualStatements ?? [],
|
|
376
|
-
quarterlyStatements: statements?.quarterlyStatements ?? [],
|
|
377
|
-
priceHistory: [],
|
|
378
|
-
};
|
|
398
|
+
const response = await apiClient.getCloudFinancials(ticker, exchange);
|
|
399
|
+
return mapCloudFinancials(unwrapRequiredCloudResponse(response, `Cloud financials are unavailable for ${ticker}`));
|
|
379
400
|
}, `Cloud financials are unavailable for ${ticker}`);
|
|
380
401
|
}
|
|
381
402
|
|
|
403
|
+
async getTickerFinancialsBatch(
|
|
404
|
+
targets: CachedFinancialsTarget[],
|
|
405
|
+
options: { forceRefresh?: boolean } = {},
|
|
406
|
+
): Promise<TickerFinancialsBatchResult[]> {
|
|
407
|
+
await requireVerifiedSession();
|
|
408
|
+
return withCloudFallback(async () => {
|
|
409
|
+
const response = await apiClient.getCloudFinancialsBatch(
|
|
410
|
+
targets.map((target) => ({
|
|
411
|
+
symbol: target.symbol,
|
|
412
|
+
exchange: target.exchange,
|
|
413
|
+
})),
|
|
414
|
+
options.forceRefresh ? "refresh" : "cache-first",
|
|
415
|
+
);
|
|
416
|
+
const payload = unwrapRequiredCloudResponse(response, "Cloud financials are unavailable");
|
|
417
|
+
return payload.items.map((item, index) => {
|
|
418
|
+
const target = targets[index] ?? {
|
|
419
|
+
symbol: item.symbol,
|
|
420
|
+
exchange: item.exchange,
|
|
421
|
+
};
|
|
422
|
+
if ((item.status === "success" || item.status === "partial") && item.data) {
|
|
423
|
+
return {
|
|
424
|
+
target,
|
|
425
|
+
financials: mapCloudFinancials(item.data),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
target,
|
|
430
|
+
financials: null,
|
|
431
|
+
error: mapBatchError(item, `Cloud financials are unavailable for ${target.symbol}`),
|
|
432
|
+
};
|
|
433
|
+
});
|
|
434
|
+
}, "Cloud financials are unavailable");
|
|
435
|
+
}
|
|
436
|
+
|
|
382
437
|
async getQuote(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise<Quote> {
|
|
383
438
|
await requireVerifiedSession();
|
|
384
439
|
return withCloudFallback(
|
|
@@ -390,6 +445,40 @@ export class GloomberbCloudProvider implements AssetDataProvider {
|
|
|
390
445
|
);
|
|
391
446
|
}
|
|
392
447
|
|
|
448
|
+
async getQuotesBatch(
|
|
449
|
+
targets: QuoteSubscriptionTarget[],
|
|
450
|
+
options: { forceRefresh?: boolean } = {},
|
|
451
|
+
): Promise<QuoteBatchResult[]> {
|
|
452
|
+
await requireVerifiedSession();
|
|
453
|
+
return withCloudFallback(async () => {
|
|
454
|
+
const response = await apiClient.getCloudQuotesBatch(
|
|
455
|
+
targets.map((target) => ({
|
|
456
|
+
symbol: target.symbol,
|
|
457
|
+
exchange: target.exchange,
|
|
458
|
+
})),
|
|
459
|
+
options.forceRefresh ? "refresh" : "cache-first",
|
|
460
|
+
);
|
|
461
|
+
const payload = unwrapRequiredCloudResponse(response, "Cloud quotes are unavailable");
|
|
462
|
+
return payload.items.map((item, index) => {
|
|
463
|
+
const target = targets[index] ?? {
|
|
464
|
+
symbol: item.symbol,
|
|
465
|
+
exchange: item.exchange,
|
|
466
|
+
};
|
|
467
|
+
if ((item.status === "success" || item.status === "partial") && item.data) {
|
|
468
|
+
return {
|
|
469
|
+
target,
|
|
470
|
+
quote: mapQuote(item.data),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
target,
|
|
475
|
+
quote: null,
|
|
476
|
+
error: mapBatchError(item, `Cloud quotes are unavailable for ${target.symbol}`),
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
}, "Cloud quotes are unavailable");
|
|
480
|
+
}
|
|
481
|
+
|
|
393
482
|
async getExchangeRate(fromCurrency: string): Promise<number> {
|
|
394
483
|
await requireVerifiedSession();
|
|
395
484
|
const response = await apiClient.getCloudExchangeRate(fromCurrency);
|
|
@@ -575,6 +664,13 @@ export function createGloomberbCloudCapabilities(provider = createGloomberbCloud
|
|
|
575
664
|
);
|
|
576
665
|
return response.items.map((item) => mapCloudNewsArticle(item, query.ticker));
|
|
577
666
|
},
|
|
667
|
+
async fetchNewsStory(storyId: string): Promise<NewsArticle | null> {
|
|
668
|
+
const story = await withCloudFallback(
|
|
669
|
+
() => apiClient.getCloudNewsStory(storyId),
|
|
670
|
+
"Cloud news story is unavailable",
|
|
671
|
+
);
|
|
672
|
+
return mapCloudNewsArticle(story);
|
|
673
|
+
},
|
|
578
674
|
},
|
|
579
675
|
}),
|
|
580
676
|
];
|
|
@@ -195,6 +195,128 @@ describe("AssetDataRouter", () => {
|
|
|
195
195
|
expect(holders.holders[0]?.name).toBe("Vanguard Group Inc");
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
+
test("falls back to later analyst providers when rating targets are missing", async () => {
|
|
199
|
+
const cloudProvider: DataProvider = {
|
|
200
|
+
...fallbackProvider,
|
|
201
|
+
id: "cloud",
|
|
202
|
+
name: "Cloud",
|
|
203
|
+
priority: 100,
|
|
204
|
+
async getAnalystResearch(symbol) {
|
|
205
|
+
return {
|
|
206
|
+
providerId: "cloud",
|
|
207
|
+
symbol,
|
|
208
|
+
ratings: [{ date: "2026-05-01", firm: "Cloud Firm", action: "Raises", current: "Buy", prior: "Buy" }],
|
|
209
|
+
recommendations: [],
|
|
210
|
+
earningsEstimates: [],
|
|
211
|
+
revenueEstimates: [],
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
const yahooProvider: DataProvider = {
|
|
216
|
+
...fallbackProvider,
|
|
217
|
+
id: "yahoo",
|
|
218
|
+
name: "Yahoo",
|
|
219
|
+
priority: 1000,
|
|
220
|
+
async getAnalystResearch(symbol) {
|
|
221
|
+
return {
|
|
222
|
+
providerId: "yahoo",
|
|
223
|
+
symbol,
|
|
224
|
+
ratings: [{
|
|
225
|
+
date: "2026-05-01",
|
|
226
|
+
firm: "Yahoo Firm",
|
|
227
|
+
action: "Raises",
|
|
228
|
+
current: "Buy",
|
|
229
|
+
prior: "Buy",
|
|
230
|
+
currentPriceTarget: 680,
|
|
231
|
+
priorPriceTarget: 595,
|
|
232
|
+
}],
|
|
233
|
+
recommendations: [],
|
|
234
|
+
earningsEstimates: [],
|
|
235
|
+
revenueEstimates: [],
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const router = new AssetDataRouter(yahooProvider, [cloudProvider]);
|
|
241
|
+
const research = await router.getAnalystResearch("AAPL", "NASDAQ");
|
|
242
|
+
|
|
243
|
+
expect(research.providerId).toBe("yahoo");
|
|
244
|
+
expect(research.ratings[0]?.currentPriceTarget).toBe(680);
|
|
245
|
+
expect(research.ratings[0]?.priorPriceTarget).toBe(595);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("prefers cached analyst records with rating targets", async () => {
|
|
249
|
+
const dbPath = createTempDbPath("cached-analyst-targets");
|
|
250
|
+
const persistence = new AppPersistence(dbPath);
|
|
251
|
+
const cloudProvider: DataProvider = {
|
|
252
|
+
...fallbackProvider,
|
|
253
|
+
id: "cloud",
|
|
254
|
+
name: "Cloud",
|
|
255
|
+
priority: 100,
|
|
256
|
+
async getAnalystResearch(symbol) {
|
|
257
|
+
return {
|
|
258
|
+
providerId: "cloud",
|
|
259
|
+
symbol,
|
|
260
|
+
ratings: [{ date: "2026-05-01", firm: "Cloud Firm", action: "Raises", current: "Buy", prior: "Buy" }],
|
|
261
|
+
recommendations: [],
|
|
262
|
+
earningsEstimates: [],
|
|
263
|
+
revenueEstimates: [],
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
const yahooProvider: DataProvider = {
|
|
268
|
+
...fallbackProvider,
|
|
269
|
+
id: "yahoo",
|
|
270
|
+
name: "Yahoo",
|
|
271
|
+
priority: 1000,
|
|
272
|
+
async getAnalystResearch(symbol) {
|
|
273
|
+
return {
|
|
274
|
+
providerId: "yahoo",
|
|
275
|
+
symbol,
|
|
276
|
+
ratings: [{
|
|
277
|
+
date: "2026-05-01",
|
|
278
|
+
firm: "Yahoo Firm",
|
|
279
|
+
action: "Raises",
|
|
280
|
+
current: "Buy",
|
|
281
|
+
prior: "Buy",
|
|
282
|
+
currentPriceTarget: 680,
|
|
283
|
+
priorPriceTarget: 595,
|
|
284
|
+
}],
|
|
285
|
+
recommendations: [],
|
|
286
|
+
earningsEstimates: [],
|
|
287
|
+
revenueEstimates: [],
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
const unavailableProvider = (id: string, priority: number): DataProvider => ({
|
|
292
|
+
...fallbackProvider,
|
|
293
|
+
id,
|
|
294
|
+
name: id,
|
|
295
|
+
priority,
|
|
296
|
+
async getAnalystResearch() {
|
|
297
|
+
throw new Error(`${id} should not be fetched`);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const seedRouter = new AssetDataRouter(yahooProvider, [cloudProvider], persistence.resources);
|
|
303
|
+
await seedRouter.getAnalystResearch("AAPL", "NASDAQ");
|
|
304
|
+
|
|
305
|
+
const cachedRouter = new AssetDataRouter(
|
|
306
|
+
unavailableProvider("yahoo", 1000),
|
|
307
|
+
[unavailableProvider("cloud", 100)],
|
|
308
|
+
persistence.resources,
|
|
309
|
+
);
|
|
310
|
+
const research = await cachedRouter.getAnalystResearch("AAPL", "NASDAQ");
|
|
311
|
+
|
|
312
|
+
expect(research.providerId).toBe("yahoo");
|
|
313
|
+
expect(research.ratings[0]?.currentPriceTarget).toBe(680);
|
|
314
|
+
expect(research.ratings[0]?.priorPriceTarget).toBe(595);
|
|
315
|
+
} finally {
|
|
316
|
+
persistence.close();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
198
320
|
test("prefers broker quotes over fallback quotes", async () => {
|
|
199
321
|
const router = new AssetDataRouter(fallbackProvider);
|
|
200
322
|
const broker: BrokerAdapter = {
|