gloomberb 0.9.6 → 0.9.8
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/api-client/auth.ts +20 -0
- package/src/api-client/index.ts +48 -0
- package/src/api-client/types.ts +45 -0
- package/src/app/runtime/desktop-deeplink.test.ts +87 -0
- package/src/app/runtime/desktop-deeplink.ts +496 -0
- package/src/app.tsx +34 -0
- package/src/components/chart/bar-chart-renderer.test.ts +87 -12
- package/src/components/chart/bar-chart-renderer.ts +111 -55
- package/src/components/chart/static/bar-chart-surface.tsx +104 -20
- package/src/components/chart/stock-chart/footer.test.ts +65 -0
- package/src/components/chart/stock-chart/footer.ts +68 -13
- package/src/components/chart/stock-chart/resolved.tsx +18 -4
- package/src/components/command-bar/pane-templates/items.ts +11 -3
- package/src/components/command-bar/routes/root/results.ts +1 -1
- package/src/components/command-bar/surface/index.test.tsx +52 -0
- package/src/components/command-bar/workflow/ops.ts +81 -20
- package/src/components/index.ts +1 -0
- package/src/components/toggle-list.tsx +29 -73
- package/src/components/ui/button.tsx +4 -1
- package/src/components/ui/checkbox.tsx +76 -0
- package/src/components/ui/choice-dialog.tsx +15 -3
- package/src/components/ui/frame.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/message-composer.tsx +4 -0
- package/src/components/ui/native-select.tsx +4 -2
- package/src/market-data/coordinator/events.test.ts +49 -0
- package/src/market-data/coordinator/events.ts +25 -4
- package/src/market-data/coordinator/index.test.ts +223 -0
- package/src/market-data/coordinator/index.ts +40 -3
- package/src/market-data/coordinator/quotes.ts +13 -5
- package/src/market-data/hooks.tsx +17 -13
- package/src/market-data/market/freshness.ts +58 -1
- package/src/market-data/quotes/freshness.test.ts +49 -1
- package/src/market-data/quotes/freshness.ts +8 -0
- package/src/market-data/quotes/resolution.test.ts +185 -0
- package/src/market-data/quotes/resolution.ts +94 -8
- package/src/market-data/request-types.test.ts +61 -1
- package/src/market-data/request-types.ts +42 -3
- package/src/pane-settings.test.ts +1 -0
- package/src/plugins/builtin/account-management/footer.ts +2 -5
- package/src/plugins/builtin/account-management/form-components.tsx +260 -24
- package/src/plugins/builtin/account-management/keyboard.ts +32 -2
- package/src/plugins/builtin/account-management/model.test.ts +99 -4
- package/src/plugins/builtin/account-management/model.ts +263 -3
- package/src/plugins/builtin/account-management/pane.tsx +784 -155
- package/src/plugins/builtin/chat/chat.test.tsx +125 -0
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
- package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
- package/src/plugins/builtin/chat/content/composer.tsx +84 -1
- package/src/plugins/builtin/chat/content/index.tsx +154 -16
- package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
- package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
- package/src/plugins/builtin/chat/content/mentions.ts +88 -0
- package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
- package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
- package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
- package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
- package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
- package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
- package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
- package/src/plugins/builtin/chat/sidebar.tsx +26 -2
- package/src/plugins/builtin/chat/test-harness.tsx +21 -11
- package/src/plugins/builtin/cloud/plugin.tsx +7 -0
- package/src/plugins/builtin/correlation/index.tsx +4 -1
- package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
- package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
- package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
- package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
- package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
- package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/ibkr/gateway/market-data.ts +12 -5
- package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
- package/src/plugins/registry/context.ts +15 -0
- package/src/plugins/registry/index.ts +31 -0
- package/src/renderers/electrobun/bun/index.ts +42 -0
- package/src/renderers/electrobun/shared/protocol.ts +5 -0
- package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
- package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
- package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
- package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
- package/src/renderers/electrobun/view/host/box.tsx +0 -2
- package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
- package/src/renderers/electrobun/view/host/text.tsx +1 -2
- package/src/renderers/electrobun/view/main.tsx +3 -0
- package/src/renderers/electrobun/view/ui-host.tsx +2 -0
- package/src/sources/provider-router/batches.ts +11 -3
- package/src/sources/provider-router/cached-financials.test.ts +123 -0
- package/src/sources/provider-router/financial-routes.ts +55 -5
- package/src/sources/provider-router/financials.test.ts +49 -0
- package/src/sources/provider-router/financials.ts +40 -3
- package/src/sources/provider-router/index.test.ts +147 -0
- package/src/sources/provider-router/primary.ts +17 -3
- package/src/sources/provider-router/streaming.ts +6 -1
- package/src/sources/sec-edgar.test.ts +42 -0
- package/src/sources/sec-edgar.ts +1 -2
- package/src/sources/yahoo-finance/mappers.test.ts +29 -0
- package/src/sources/yahoo-finance/mappers.ts +2 -1
- package/src/sources/yahoo-finance/requests.ts +2 -1
- package/src/sources/yahoo-finance/snapshots.ts +13 -2
- package/src/sources/yahoo-finance.ts +6 -2
- package/src/state/hooks/quote-streaming.test.ts +14 -1
- package/src/state/hooks/quote-streaming.ts +5 -4
- package/src/sync/controller.ts +296 -0
- package/src/sync/core-contributors.test.ts +251 -0
- package/src/sync/core-contributors.ts +400 -0
- package/src/sync/profile-analytics.ts +39 -0
- package/src/sync/react.ts +53 -0
- package/src/sync/types.ts +77 -0
- package/src/types/config.ts +1 -0
- package/src/types/desktop-deeplink.ts +7 -0
- package/src/types/plugin.ts +3 -0
- package/src/ui/host.tsx +14 -1
- package/src/ui/index.tsx +1 -0
- package/src/utils/exchanges.test.ts +11 -1
- package/src/utils/exchanges.ts +5 -1
- package/src/version.ts +1 -1
|
@@ -20,24 +20,29 @@ function DefaultDesktopRow({
|
|
|
20
20
|
selected: boolean;
|
|
21
21
|
}) {
|
|
22
22
|
return (
|
|
23
|
-
<Box
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
<Box
|
|
24
|
+
flexDirection="row"
|
|
25
|
+
justifyContent="space-between"
|
|
26
|
+
width="100%"
|
|
27
|
+
minWidth={0}
|
|
28
|
+
alignItems="center"
|
|
29
|
+
style={{ boxSizing: "border-box" }}
|
|
30
|
+
>
|
|
31
|
+
<Box flexDirection="row" alignItems="center" minWidth={0} flexShrink={1}>
|
|
32
32
|
<Text
|
|
33
33
|
fg={selected ? colors.text : colors.textDim}
|
|
34
34
|
attributes={selected ? TextAttributes.BOLD : 0}
|
|
35
|
+
style={{
|
|
36
|
+
overflow: "hidden",
|
|
37
|
+
textOverflow: "ellipsis",
|
|
38
|
+
whiteSpace: "nowrap",
|
|
39
|
+
}}
|
|
35
40
|
>
|
|
36
41
|
{item.label}
|
|
37
42
|
</Text>
|
|
38
43
|
</Box>
|
|
39
44
|
{item.detail && (
|
|
40
|
-
<Text fg={colors.textMuted}>
|
|
45
|
+
<Text fg={colors.textMuted} style={{ flexShrink: 0, marginLeft: 12 }}>
|
|
41
46
|
{item.detail}
|
|
42
47
|
</Text>
|
|
43
48
|
)}
|
|
@@ -50,7 +55,11 @@ function listRowStyle(selected: boolean): CSSProperties {
|
|
|
50
55
|
borderRadius: CONTROL_RADIUS,
|
|
51
56
|
border: `1px solid ${selected ? colors.borderFocused : "transparent"}`,
|
|
52
57
|
boxShadow: selected ? `inset 0 1px 0 ${blendHex(colors.bg, colors.textBright, 0.06)}` : undefined,
|
|
58
|
+
boxSizing: "border-box",
|
|
53
59
|
cursor: "pointer",
|
|
60
|
+
maxWidth: "100%",
|
|
61
|
+
minWidth: 0,
|
|
62
|
+
paddingInline: 10,
|
|
54
63
|
};
|
|
55
64
|
}
|
|
56
65
|
|
|
@@ -69,10 +78,11 @@ export function WebListView({
|
|
|
69
78
|
hoverBgColor,
|
|
70
79
|
rowGap = 1,
|
|
71
80
|
rowHeight = 1,
|
|
72
|
-
surface
|
|
81
|
+
surface,
|
|
73
82
|
height,
|
|
74
83
|
flexGrow,
|
|
75
84
|
scrollable = false,
|
|
85
|
+
selectOnHover = false,
|
|
76
86
|
autoScrollToIndex = true,
|
|
77
87
|
}: ListViewProps) {
|
|
78
88
|
useThemeColors();
|
|
@@ -83,6 +93,20 @@ export function WebListView({
|
|
|
83
93
|
const rowHoverBg = hoverBgColor ?? hoverBg();
|
|
84
94
|
const selectedItem = selectedIndex >= 0 ? items[selectedIndex] : undefined;
|
|
85
95
|
const activeScrollIndex = scrollIndex ?? selectedIndex;
|
|
96
|
+
const effectiveSurface = surface ?? (scrollable ? "framed" : "plain");
|
|
97
|
+
const frameStyle = effectiveSurface === "plain"
|
|
98
|
+
? {
|
|
99
|
+
border: "none",
|
|
100
|
+
borderRadius: 0,
|
|
101
|
+
padding: 0,
|
|
102
|
+
backgroundColor: "transparent",
|
|
103
|
+
}
|
|
104
|
+
: {
|
|
105
|
+
border: `1px solid ${panelBorder()}`,
|
|
106
|
+
borderRadius: CONTROL_RADIUS,
|
|
107
|
+
padding: 4,
|
|
108
|
+
backgroundColor: subtlePanelFill(),
|
|
109
|
+
};
|
|
86
110
|
|
|
87
111
|
useEffect(() => {
|
|
88
112
|
if (!scrollable || !autoScrollToIndex || activeScrollIndex < 0) return;
|
|
@@ -130,6 +154,7 @@ export function WebListView({
|
|
|
130
154
|
onMouseMove={() => {
|
|
131
155
|
if (!disabled) {
|
|
132
156
|
setHoveredIndex((current) => (current === index ? current : index));
|
|
157
|
+
if (selectOnHover) onSelect?.(index);
|
|
133
158
|
}
|
|
134
159
|
}}
|
|
135
160
|
onMouseDown={() => {
|
|
@@ -156,26 +181,14 @@ export function WebListView({
|
|
|
156
181
|
flexGrow={flexGrow}
|
|
157
182
|
scrollY
|
|
158
183
|
focusable={false}
|
|
159
|
-
style={
|
|
160
|
-
? {
|
|
161
|
-
border: "none",
|
|
162
|
-
borderRadius: 0,
|
|
163
|
-
padding: 0,
|
|
164
|
-
backgroundColor: "transparent",
|
|
165
|
-
}
|
|
166
|
-
: {
|
|
167
|
-
border: `1px solid ${panelBorder()}`,
|
|
168
|
-
borderRadius: CONTROL_RADIUS,
|
|
169
|
-
padding: 4,
|
|
170
|
-
backgroundColor: subtlePanelFill(),
|
|
171
|
-
}}
|
|
184
|
+
style={frameStyle}
|
|
172
185
|
>
|
|
173
186
|
<Box flexDirection="column" gap={rowGap}>
|
|
174
187
|
{rows}
|
|
175
188
|
</Box>
|
|
176
189
|
</ScrollBox>
|
|
177
190
|
) : (
|
|
178
|
-
<Box flexDirection="column" gap={rowGap}>
|
|
191
|
+
<Box flexDirection="column" gap={rowGap} style={frameStyle}>
|
|
179
192
|
{rows}
|
|
180
193
|
</Box>
|
|
181
194
|
)}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DesktopDeepLinkBridge } from "../../../types/desktop-deeplink";
|
|
2
|
+
import { onDesktopDeepLink } from "./backend-rpc";
|
|
3
|
+
|
|
4
|
+
export function createDesktopDeepLinkBridge(): DesktopDeepLinkBridge {
|
|
5
|
+
return {
|
|
6
|
+
subscribe(listener) {
|
|
7
|
+
return onDesktopDeepLink(listener);
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
cancelWebFrame,
|
|
20
20
|
cellBoundsForElement,
|
|
21
21
|
cellMouseEvent,
|
|
22
|
-
hasDirectMouseHandler,
|
|
23
22
|
requestWebFrame,
|
|
24
23
|
} from "./mouse";
|
|
25
24
|
import { cleanDomProps, commonStyle } from "./style";
|
|
@@ -148,7 +147,6 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
|
|
|
148
147
|
<div
|
|
149
148
|
{...cleanDomProps(props)}
|
|
150
149
|
data-gloom-hover-bg={hoverBackgroundColor ? "true" : undefined}
|
|
151
|
-
data-gloom-interactive={hasDirectMouseHandler(props) ? "true" : undefined}
|
|
152
150
|
ref={elementRef}
|
|
153
151
|
onMouseDown={handleMouseDown}
|
|
154
152
|
onMouseOver={handleMouseOver}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "react";
|
|
15
15
|
import type { ScrollBoxRenderable } from "../../../../ui/host";
|
|
16
16
|
import { WEB_CELL_HEIGHT, WEB_CELL_WIDTH } from "../input-host";
|
|
17
|
-
import { callMouseHandler
|
|
17
|
+
import { callMouseHandler } from "./mouse";
|
|
18
18
|
import { cleanDomProps, commonStyle } from "./style";
|
|
19
19
|
import { useScrollbarActivity } from "../scrollbar-activity";
|
|
20
20
|
|
|
@@ -254,7 +254,6 @@ export const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unkno
|
|
|
254
254
|
data-gloom-scrollbar-x={props.scrollX === true ? (horizontalScrollBarVisible ? "visible" : "hidden") : undefined}
|
|
255
255
|
data-gloom-scrollbar-y={props.scrollY === true ? (verticalScrollBarVisible ? "visible" : "hidden") : undefined}
|
|
256
256
|
data-gloom-scrollbar-active={scrollbarActive ? "true" : undefined}
|
|
257
|
-
data-gloom-interactive={hasDirectMouseHandler(props) ? "true" : undefined}
|
|
258
257
|
onMouseDown={(event) => callMouseHandler(props.onMouseDown, event, "down")}
|
|
259
258
|
onMouseMove={(event) => callMouseHandler(props.onMouseMove, event, "move")}
|
|
260
259
|
onMouseUp={(event) => callMouseHandler(props.onMouseUp, event, "up")}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "../../../../ui/host";
|
|
10
10
|
import { WEB_CELL_WIDTH } from "../input-host";
|
|
11
11
|
import { webAsciiTextLines, webAsciiTextWordmarkVariant } from "./ascii-text";
|
|
12
|
-
import {
|
|
12
|
+
import { mouseHandlers } from "./mouse";
|
|
13
13
|
import { cleanDomProps, commonStyle, textStyle } from "./style";
|
|
14
14
|
|
|
15
15
|
const WEB_WORDMARK_CHAR_WIDTH_PX = Math.max(10, WEB_CELL_WIDTH);
|
|
@@ -56,7 +56,6 @@ export function WebText({ children, ...props }: TextProps) {
|
|
|
56
56
|
<span
|
|
57
57
|
{...cleanDomProps(props)}
|
|
58
58
|
{...mouseHandlers(props)}
|
|
59
|
-
data-gloom-interactive={hasDirectMouseHandler(props) ? "true" : undefined}
|
|
60
59
|
style={{ ...textStyle(props), ...(props.style as CSSProperties | undefined) }}
|
|
61
60
|
>
|
|
62
61
|
{renderTextContent(children, props.content, props)}
|
|
@@ -21,6 +21,7 @@ import { webNativeRenderer } from "./native-renderer";
|
|
|
21
21
|
import { WebToastHostProvider } from "./toast-host";
|
|
22
22
|
import { createWebUiHost, webRendererHost } from "./ui-host";
|
|
23
23
|
import { createApplicationMenuBridge } from "./application-menu-bridge";
|
|
24
|
+
import { createDesktopDeepLinkBridge } from "./desktop-deeplink-bridge";
|
|
24
25
|
import { createDesktopWindowBridge } from "./desktop/window/bridge";
|
|
25
26
|
import { prepareDetachedSnapshot } from "./desktop/window/snapshot";
|
|
26
27
|
|
|
@@ -88,6 +89,7 @@ async function boot() {
|
|
|
88
89
|
const config = desktopSnapshot?.config ?? init.config;
|
|
89
90
|
const desktopWindowBridge = createDesktopWindowBridge(init.windowKind, init.paneId);
|
|
90
91
|
const desktopApplicationMenuBridge = createApplicationMenuBridge();
|
|
92
|
+
const desktopDeepLinkBridge = createDesktopDeepLinkBridge();
|
|
91
93
|
const webUiHost = createWebUiHost(init.desktopPlatform);
|
|
92
94
|
const remoteControlAdapter = init.windowKind === "main"
|
|
93
95
|
? { registerHandler: setElectrobunRemoteRequestHandler }
|
|
@@ -103,6 +105,7 @@ async function boot() {
|
|
|
103
105
|
config={config}
|
|
104
106
|
desktopWindowBridge={desktopWindowBridge}
|
|
105
107
|
desktopApplicationMenuBridge={desktopApplicationMenuBridge}
|
|
108
|
+
desktopDeepLinkBridge={desktopDeepLinkBridge}
|
|
106
109
|
desktopSnapshot={desktopSnapshot}
|
|
107
110
|
desktopThemePreview={init.desktopThemePreview}
|
|
108
111
|
remoteControlAdapter={remoteControlAdapter}
|
|
@@ -7,6 +7,7 @@ import { backendRequest } from "./backend-rpc";
|
|
|
7
7
|
import { WebDataTable } from "./data-table";
|
|
8
8
|
import {
|
|
9
9
|
WebButton,
|
|
10
|
+
WebCheckbox,
|
|
10
11
|
WebDialogFrame,
|
|
11
12
|
WebListView,
|
|
12
13
|
WebMessageComposer,
|
|
@@ -82,6 +83,7 @@ export function createWebUiHost(desktopPlatform?: string): UiHost {
|
|
|
82
83
|
Input: WebInput,
|
|
83
84
|
Textarea: WebTextarea,
|
|
84
85
|
Button: WebButton,
|
|
86
|
+
Checkbox: WebCheckbox,
|
|
85
87
|
TextField: WebTextField,
|
|
86
88
|
MessageComposer: WebMessageComposer,
|
|
87
89
|
ListView: WebListView,
|
|
@@ -12,8 +12,10 @@ import { isQuoteStaleForCurrentSession } from "../../market-data/quotes/freshnes
|
|
|
12
12
|
import { resolveTickerFinancialsQuoteState } from "../../market-data/quotes/resolution";
|
|
13
13
|
import { selectCachedResource } from "./cache";
|
|
14
14
|
import {
|
|
15
|
+
dropUnusableProviderQuote,
|
|
15
16
|
hasDeepStatementHistory,
|
|
16
17
|
hasDetailedStatementRows,
|
|
18
|
+
isProviderQuoteUsableForCurrentSession,
|
|
17
19
|
quoteWithFreshnessExchange,
|
|
18
20
|
type CachedFinancialsSelection,
|
|
19
21
|
} from "./financials";
|
|
@@ -52,8 +54,9 @@ export class ProviderRouterBatchRoutes {
|
|
|
52
54
|
const context = target.context;
|
|
53
55
|
const entityKey = this.deps.getEntityKey(target.symbol, context?.instrument);
|
|
54
56
|
const variantKeys = this.deps.getTickerVariantCandidates(target.exchange);
|
|
57
|
+
const brokerSourceKeys = this.deps.getBrokerCandidatesForContext(context, false).map((candidate) => this.deps.brokerSourceKey(candidate));
|
|
55
58
|
const sourceKeys = [
|
|
56
|
-
...
|
|
59
|
+
...brokerSourceKeys,
|
|
57
60
|
...this.deps.getProviderSourceKeys(),
|
|
58
61
|
];
|
|
59
62
|
const rawCached = selectCachedResource<Quote>(this.deps.resources, "quote", entityKey, variantKeys, sourceKeys, false);
|
|
@@ -61,6 +64,10 @@ export class ProviderRouterBatchRoutes {
|
|
|
61
64
|
? rawCached
|
|
62
65
|
: null;
|
|
63
66
|
if (cached && !forceRefresh && !cached.stale) {
|
|
67
|
+
if (brokerSourceKeys.includes(cached.sourceKey)) {
|
|
68
|
+
misses.push({ index, target });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
64
71
|
results[index] = { target, quote: cached.value };
|
|
65
72
|
return;
|
|
66
73
|
}
|
|
@@ -80,7 +87,7 @@ export class ProviderRouterBatchRoutes {
|
|
|
80
87
|
const uniqueTargets = [...providerIndexes.values()].map((bucket) => bucket[0]!.target);
|
|
81
88
|
const batchResults = await batchProvider.getQuotesBatch!(uniqueTargets, options).catch(() => []);
|
|
82
89
|
for (const item of batchResults) {
|
|
83
|
-
if (!item.quote
|
|
90
|
+
if (!isProviderQuoteUsableForCurrentSession(item.quote, item.target.exchange)) continue;
|
|
84
91
|
const key = this.quoteBatchKey(item.target);
|
|
85
92
|
const sourceKey = this.deps.providerSourceKey(batchProvider);
|
|
86
93
|
for (const entry of providerIndexes.get(key) ?? []) {
|
|
@@ -142,7 +149,8 @@ export class ProviderRouterBatchRoutes {
|
|
|
142
149
|
for (const item of batchResults) {
|
|
143
150
|
if (!item.financials) continue;
|
|
144
151
|
const key = this.cachedFinancialsBatchKey(item.target);
|
|
145
|
-
|
|
152
|
+
let value = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(item.financials));
|
|
153
|
+
if (value) value = dropUnusableProviderQuote(value, item.target.exchange);
|
|
146
154
|
if (!value) continue;
|
|
147
155
|
const sourceKey = this.deps.providerSourceKey(batchProvider);
|
|
148
156
|
for (const entry of providerIndexes.get(key) ?? []) {
|
|
@@ -405,6 +405,129 @@ describe("AssetDataRouter cached financials", () => {
|
|
|
405
405
|
persistence.close();
|
|
406
406
|
});
|
|
407
407
|
|
|
408
|
+
test("uses symbol provider quote reference for broker-linked snapshot reads", async () => {
|
|
409
|
+
const dbPath = createTempDbPath("cached-symbol-reference-for-contract-snapshot");
|
|
410
|
+
const persistence = new AppPersistence(dbPath);
|
|
411
|
+
const now = Date.now();
|
|
412
|
+
|
|
413
|
+
persistence.resources.set(
|
|
414
|
+
{
|
|
415
|
+
namespace: "market",
|
|
416
|
+
kind: "financials",
|
|
417
|
+
entityKey: "contract:275759",
|
|
418
|
+
variantKey: "exchange=NASDAQ",
|
|
419
|
+
sourceKey: "provider:gloomberb-cloud",
|
|
420
|
+
},
|
|
421
|
+
makeFinancials({
|
|
422
|
+
quote: makeQuote({
|
|
423
|
+
symbol: "VICR",
|
|
424
|
+
providerId: "gloomberb-cloud",
|
|
425
|
+
price: 292.83,
|
|
426
|
+
currency: "USD",
|
|
427
|
+
previousClose: 380.07,
|
|
428
|
+
change: -87.24,
|
|
429
|
+
changePercent: -22.95,
|
|
430
|
+
lastUpdated: now,
|
|
431
|
+
marketState: "REGULAR",
|
|
432
|
+
exchangeName: "NASDAQ",
|
|
433
|
+
listingExchangeName: "NASDAQ",
|
|
434
|
+
dataSource: "live",
|
|
435
|
+
}),
|
|
436
|
+
profile: {
|
|
437
|
+
description: "Cloud profile",
|
|
438
|
+
},
|
|
439
|
+
fundamentals: {
|
|
440
|
+
trailingPE: 94.3,
|
|
441
|
+
},
|
|
442
|
+
}),
|
|
443
|
+
{
|
|
444
|
+
cachePolicy: { staleMs: 60_000, expireMs: 7 * 24 * 60 * 60_000 },
|
|
445
|
+
fetchedAt: now,
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
persistence.resources.set(
|
|
449
|
+
{
|
|
450
|
+
namespace: "market",
|
|
451
|
+
kind: "financials",
|
|
452
|
+
entityKey: "contract:275759",
|
|
453
|
+
variantKey: "exchange=NASDAQ",
|
|
454
|
+
sourceKey: "provider:yahoo",
|
|
455
|
+
},
|
|
456
|
+
makeFinancials({
|
|
457
|
+
annualStatements: [{ date: "2025-12-31", totalRevenue: 100 }],
|
|
458
|
+
}),
|
|
459
|
+
{
|
|
460
|
+
cachePolicy: { staleMs: 60_000, expireMs: 7 * 24 * 60 * 60_000 },
|
|
461
|
+
fetchedAt: now + 1_000,
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
persistence.resources.set(
|
|
465
|
+
{
|
|
466
|
+
namespace: "market",
|
|
467
|
+
kind: "financials",
|
|
468
|
+
entityKey: "VICR",
|
|
469
|
+
variantKey: "exchange=NASDAQ",
|
|
470
|
+
sourceKey: "provider:yahoo",
|
|
471
|
+
},
|
|
472
|
+
makeFinancials({
|
|
473
|
+
quote: makeQuote({
|
|
474
|
+
symbol: "VICR",
|
|
475
|
+
providerId: "yahoo",
|
|
476
|
+
price: 294.39,
|
|
477
|
+
currency: "USD",
|
|
478
|
+
previousClose: 282.95,
|
|
479
|
+
change: 11.44,
|
|
480
|
+
changePercent: 4.04,
|
|
481
|
+
lastUpdated: now - 1_000,
|
|
482
|
+
marketState: "REGULAR",
|
|
483
|
+
exchangeName: "NASDAQ",
|
|
484
|
+
listingExchangeName: "NASDAQ",
|
|
485
|
+
dataSource: "delayed",
|
|
486
|
+
}),
|
|
487
|
+
}),
|
|
488
|
+
{
|
|
489
|
+
cachePolicy: { staleMs: 60_000, expireMs: 7 * 24 * 60 * 60_000 },
|
|
490
|
+
fetchedAt: now - 1_000,
|
|
491
|
+
},
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const router = new AssetDataRouter({
|
|
495
|
+
...fallbackProvider,
|
|
496
|
+
id: "yahoo",
|
|
497
|
+
name: "Yahoo",
|
|
498
|
+
async getTickerFinancials() {
|
|
499
|
+
throw new Error("should not fetch financials");
|
|
500
|
+
},
|
|
501
|
+
}, [{
|
|
502
|
+
...fallbackProvider,
|
|
503
|
+
id: "gloomberb-cloud",
|
|
504
|
+
name: "Cloud",
|
|
505
|
+
priority: 100,
|
|
506
|
+
async getTickerFinancials() {
|
|
507
|
+
throw new Error("should not fetch financials");
|
|
508
|
+
},
|
|
509
|
+
}], persistence.resources);
|
|
510
|
+
|
|
511
|
+
const financials = await router.getTickerFinancials("VICR", "NASDAQ", {
|
|
512
|
+
brokerId: "ibkr",
|
|
513
|
+
brokerInstanceId: "ibkr-work",
|
|
514
|
+
instrument: {
|
|
515
|
+
brokerId: "ibkr",
|
|
516
|
+
brokerInstanceId: "ibkr-work",
|
|
517
|
+
conId: 275759,
|
|
518
|
+
symbol: "VICR",
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
expect(financials.quote?.providerId).toBe("gloomberb-cloud");
|
|
523
|
+
expect(financials.quote?.price).toBe(292.83);
|
|
524
|
+
expect(financials.quote?.previousClose).toBe(282.95);
|
|
525
|
+
expect(financials.quote?.changePercent).toBeCloseTo(((292.83 - 282.95) / 282.95) * 100, 10);
|
|
526
|
+
expect(financials.quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
|
|
527
|
+
|
|
528
|
+
persistence.close();
|
|
529
|
+
});
|
|
530
|
+
|
|
408
531
|
test("overlays standalone quote cache on stale financial snapshots during startup priming", () => {
|
|
409
532
|
const dbPath = createTempDbPath("cached-quote-over-financial-snapshot");
|
|
410
533
|
const persistence = new AppPersistence(dbPath);
|
|
@@ -81,7 +81,9 @@ export class ProviderRouterFinancialRoutes {
|
|
|
81
81
|
quarterlyStatements: base?.quarterlyStatements ?? [],
|
|
82
82
|
priceHistory: base?.priceHistory ?? [],
|
|
83
83
|
});
|
|
84
|
-
const cached = this.readCachedMergedFinancialsSelection(ticker, exchange, context, false
|
|
84
|
+
const cached = this.readCachedMergedFinancialsSelection(ticker, exchange, context, false, {
|
|
85
|
+
includeSymbolProviderFallback: true,
|
|
86
|
+
});
|
|
85
87
|
const forceRefresh = context?.cacheMode === "refresh";
|
|
86
88
|
if (cached.value && !forceRefresh) {
|
|
87
89
|
if (isOptionTicker && !cached.value.quote) {
|
|
@@ -128,8 +130,9 @@ export class ProviderRouterFinancialRoutes {
|
|
|
128
130
|
async getQuote(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<Quote> {
|
|
129
131
|
const entityKey = this.deps.getEntityKey(ticker, context?.instrument);
|
|
130
132
|
const variantKeys = this.deps.getTickerVariantCandidates(exchange);
|
|
133
|
+
const brokerSourceKeys = this.deps.getBrokerCandidatesForContext(context, false).map((candidate) => this.deps.brokerSourceKey(candidate));
|
|
131
134
|
const sourceKeys = [
|
|
132
|
-
...
|
|
135
|
+
...brokerSourceKeys,
|
|
133
136
|
...this.deps.getProviderSourceKeys(),
|
|
134
137
|
];
|
|
135
138
|
const rawCached = selectCachedResource<Quote>(this.deps.resources, "quote", entityKey, variantKeys, sourceKeys, false);
|
|
@@ -138,12 +141,19 @@ export class ProviderRouterFinancialRoutes {
|
|
|
138
141
|
: null;
|
|
139
142
|
const forceRefresh = context?.cacheMode === "refresh";
|
|
140
143
|
if (cached && !forceRefresh && !cached.stale) {
|
|
141
|
-
return cached.value;
|
|
144
|
+
if (!brokerSourceKeys.includes(cached.sourceKey)) return cached.value;
|
|
145
|
+
return this.mergeBrokerQuoteWithProviderReference(
|
|
146
|
+
cached.value,
|
|
147
|
+
await this.getProviderReferenceQuote(ticker, exchange, context),
|
|
148
|
+
);
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
const brokerQuote = await withBrokerTimeout(this.deps.primaryRoutes.fetchBrokerQuote(ticker, exchange, context));
|
|
145
152
|
if (brokerQuote && !isQuoteStaleForCurrentSession(quoteWithFreshnessExchange(brokerQuote.value, exchange))) {
|
|
146
|
-
return
|
|
153
|
+
return this.mergeBrokerQuoteWithProviderReference(
|
|
154
|
+
brokerQuote.value,
|
|
155
|
+
await this.getProviderReferenceQuote(ticker, exchange, context),
|
|
156
|
+
);
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
const providerQuote = await this.deps.primaryRoutes.fetchProviderQuote(ticker, exchange, context);
|
|
@@ -154,6 +164,45 @@ export class ProviderRouterFinancialRoutes {
|
|
|
154
164
|
throw new Error(`No quote provider available for ${ticker}`);
|
|
155
165
|
}
|
|
156
166
|
|
|
167
|
+
private async getProviderReferenceQuote(
|
|
168
|
+
ticker: string,
|
|
169
|
+
exchange?: string,
|
|
170
|
+
context?: MarketDataRequestContext,
|
|
171
|
+
): Promise<Quote | null> {
|
|
172
|
+
const cached = this.readCachedProviderQuote(ticker, exchange, context);
|
|
173
|
+
if (cached) return cached;
|
|
174
|
+
const providerQuote = await this.deps.primaryRoutes.fetchProviderQuote(ticker, exchange, context);
|
|
175
|
+
return providerQuote?.value ?? null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private readCachedProviderQuote(
|
|
179
|
+
ticker: string,
|
|
180
|
+
exchange?: string,
|
|
181
|
+
context?: MarketDataRequestContext,
|
|
182
|
+
): Quote | null {
|
|
183
|
+
const entityKey = this.deps.getEntityKey(ticker, context?.instrument);
|
|
184
|
+
const entityKeys = [...new Set([entityKey, normalizeTicker(ticker)])];
|
|
185
|
+
const variantKeys = this.deps.getTickerVariantCandidates(exchange);
|
|
186
|
+
const sourceKeys = this.deps.getProviderSourceKeys();
|
|
187
|
+
for (const candidateEntityKey of entityKeys) {
|
|
188
|
+
const record = selectCachedResource<Quote>(this.deps.resources, "quote", candidateEntityKey, variantKeys, sourceKeys, false);
|
|
189
|
+
if (record && !isQuoteStaleForCurrentSession(quoteWithFreshnessExchange(record.value, exchange))) {
|
|
190
|
+
return record.value;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private mergeBrokerQuoteWithProviderReference(brokerQuote: Quote, providerQuote: Quote | null): Quote {
|
|
197
|
+
if (!providerQuote) return brokerQuote;
|
|
198
|
+
return resolveTickerFinancialsQuoteState({
|
|
199
|
+
quote: providerQuote,
|
|
200
|
+
annualStatements: [],
|
|
201
|
+
quarterlyStatements: [],
|
|
202
|
+
priceHistory: [],
|
|
203
|
+
}, brokerQuote)?.quote ?? brokerQuote;
|
|
204
|
+
}
|
|
205
|
+
|
|
157
206
|
readCachedMergedFinancialsSelection(
|
|
158
207
|
ticker: string,
|
|
159
208
|
exchange?: string,
|
|
@@ -171,7 +220,8 @@ export class ProviderRouterFinancialRoutes {
|
|
|
171
220
|
? { ...brokerRecord, value: sanitizeCachedFinancials(brokerRecord.value, options) }
|
|
172
221
|
: null;
|
|
173
222
|
const providerSourceKeys = this.deps.getProviderSourceKeys();
|
|
174
|
-
const
|
|
223
|
+
const includeSymbolProviderFallback = options.includeSymbolProviderFallback !== false;
|
|
224
|
+
const providerEntityKeys = includeSymbolProviderFallback
|
|
175
225
|
? [...new Set([entityKey, normalizeTicker(ticker)])]
|
|
176
226
|
: [entityKey];
|
|
177
227
|
const providerRecords = sortCachedRecords(
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
dropUnusableProviderQuote,
|
|
4
|
+
isProviderQuoteUsableForCurrentSession,
|
|
5
|
+
} from "./financials";
|
|
6
|
+
import { makeFinancials, makeQuote } from "./test-support";
|
|
7
|
+
|
|
8
|
+
describe("provider-router financial quote usability", () => {
|
|
9
|
+
test("rejects active-session labels without active-session prices", () => {
|
|
10
|
+
expect(isProviderQuoteUsableForCurrentSession(makeQuote({
|
|
11
|
+
listingExchangeName: "NASDAQ",
|
|
12
|
+
marketState: "PRE",
|
|
13
|
+
lastUpdated: Date.now(),
|
|
14
|
+
}), "NASDAQ")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("rejects old active-session provider quotes", () => {
|
|
18
|
+
expect(isProviderQuoteUsableForCurrentSession(makeQuote({
|
|
19
|
+
listingExchangeName: "FWB2",
|
|
20
|
+
marketState: "REGULAR",
|
|
21
|
+
lastUpdated: Date.now() - 20 * 60_000,
|
|
22
|
+
}), "FWB2")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("rejects empty zero provider quotes", () => {
|
|
26
|
+
expect(isProviderQuoteUsableForCurrentSession(makeQuote({
|
|
27
|
+
price: 0,
|
|
28
|
+
change: 0,
|
|
29
|
+
changePercent: 0,
|
|
30
|
+
listingExchangeName: "SFB",
|
|
31
|
+
lastUpdated: Date.now(),
|
|
32
|
+
}), "SFB")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("strips unusable quotes while preserving non-quote financials", () => {
|
|
36
|
+
const value = dropUnusableProviderQuote(makeFinancials({
|
|
37
|
+
profile: { sector: "Industrials" },
|
|
38
|
+
quote: makeQuote({
|
|
39
|
+
price: 0,
|
|
40
|
+
change: 0,
|
|
41
|
+
changePercent: 0,
|
|
42
|
+
listingExchangeName: "SFB",
|
|
43
|
+
}),
|
|
44
|
+
}), "SFB");
|
|
45
|
+
|
|
46
|
+
expect(value.profile?.sector).toBe("Industrials");
|
|
47
|
+
expect(value.quote).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -77,6 +77,46 @@ export function quoteWithFreshnessExchange(quote: Quote, exchange?: string): Quo
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function finitePositiveNumber(value: unknown): value is number {
|
|
81
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ACTIVE_PROVIDER_QUOTE_MAX_AGE_MS = 10 * 60_000;
|
|
85
|
+
const ACTIVE_QUOTE_MARKET_STATES = new Set(["PRE", "REGULAR", "POST"]);
|
|
86
|
+
|
|
87
|
+
function isActiveProviderQuoteTooOld(quote: Quote, now = Date.now()): boolean {
|
|
88
|
+
if (!ACTIVE_QUOTE_MARKET_STATES.has(quote.marketState ?? "")) return false;
|
|
89
|
+
if (!Number.isFinite(quote.lastUpdated)) return false;
|
|
90
|
+
return now - quote.lastUpdated > ACTIVE_PROVIDER_QUOTE_MAX_AGE_MS;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isProviderQuoteUsableForCurrentSession(quote: Quote | null | undefined, exchange?: string): quote is Quote {
|
|
94
|
+
if (!quote) return false;
|
|
95
|
+
const normalized = quoteWithFreshnessExchange(quote, exchange);
|
|
96
|
+
if (isQuoteStaleForCurrentSession(normalized)) return false;
|
|
97
|
+
if (isActiveProviderQuoteTooOld(normalized)) return false;
|
|
98
|
+
return [
|
|
99
|
+
normalized.price,
|
|
100
|
+
normalized.preMarketPrice,
|
|
101
|
+
normalized.postMarketPrice,
|
|
102
|
+
normalized.bid,
|
|
103
|
+
normalized.ask,
|
|
104
|
+
normalized.mark,
|
|
105
|
+
].some(finitePositiveNumber);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function dropUnusableProviderQuote(value: TickerFinancials, exchange?: string): TickerFinancials {
|
|
109
|
+
if (!value.quote || isProviderQuoteUsableForCurrentSession(value.quote, exchange)) {
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
...value,
|
|
115
|
+
quote: undefined,
|
|
116
|
+
quoteContributions: undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
80
120
|
function sanitizeCachedQuote(
|
|
81
121
|
quote: Quote,
|
|
82
122
|
exchange: string | undefined,
|
|
@@ -216,13 +256,10 @@ export function mergeCachedFinancialRecords(
|
|
|
216
256
|
value: TickerFinancials | null;
|
|
217
257
|
stale: boolean;
|
|
218
258
|
} {
|
|
219
|
-
const seenSources = new Set<string>();
|
|
220
259
|
let merged: TickerFinancials | null = null;
|
|
221
260
|
let stale = false;
|
|
222
261
|
|
|
223
262
|
for (const record of records) {
|
|
224
|
-
if (seenSources.has(record.sourceKey)) continue;
|
|
225
|
-
seenSources.add(record.sourceKey);
|
|
226
263
|
merged = mergeFinancials(merged, sanitizeCachedFinancials(record.value, options));
|
|
227
264
|
stale = stale || record.stale === true;
|
|
228
265
|
}
|