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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type ComponentType } from "react";
|
|
2
|
+
import { Box, Text, TextAttributes, useUiHost, type HostCheckboxProps } from "../../ui";
|
|
3
|
+
import { colors, hoverBg } from "../../theme/colors";
|
|
4
|
+
import { useRemoteUiNode } from "../../remote/semantic-tree";
|
|
5
|
+
|
|
6
|
+
export type CheckboxProps = HostCheckboxProps;
|
|
7
|
+
|
|
8
|
+
export function Checkbox({
|
|
9
|
+
label,
|
|
10
|
+
displayLabel,
|
|
11
|
+
checked,
|
|
12
|
+
onChange,
|
|
13
|
+
disabled = false,
|
|
14
|
+
active = false,
|
|
15
|
+
description,
|
|
16
|
+
width,
|
|
17
|
+
variant = "default",
|
|
18
|
+
}: CheckboxProps) {
|
|
19
|
+
useRemoteUiNode({
|
|
20
|
+
role: "checkbox",
|
|
21
|
+
label,
|
|
22
|
+
disabled,
|
|
23
|
+
actions: {
|
|
24
|
+
toggle: () => {
|
|
25
|
+
if (!disabled) onChange?.(!checked);
|
|
26
|
+
},
|
|
27
|
+
press: () => {
|
|
28
|
+
if (!disabled) onChange?.(!checked);
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
metadata: { checked, active, variant },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const HostCheckbox = useUiHost().Checkbox as ComponentType<CheckboxProps> | undefined;
|
|
35
|
+
if (HostCheckbox) {
|
|
36
|
+
return (
|
|
37
|
+
<HostCheckbox
|
|
38
|
+
label={label}
|
|
39
|
+
displayLabel={displayLabel}
|
|
40
|
+
checked={checked}
|
|
41
|
+
onChange={onChange}
|
|
42
|
+
disabled={disabled}
|
|
43
|
+
active={active}
|
|
44
|
+
description={description}
|
|
45
|
+
width={width}
|
|
46
|
+
variant={variant}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const marker = checked ? "\u2713" : " ";
|
|
52
|
+
const visibleLabel = displayLabel ?? label;
|
|
53
|
+
const fg = disabled ? colors.textMuted : active ? colors.textBright : colors.text;
|
|
54
|
+
const descriptionWidth = typeof width === "number" ? Math.max(20, width - 2) : 28;
|
|
55
|
+
return (
|
|
56
|
+
<Box
|
|
57
|
+
flexDirection="column"
|
|
58
|
+
width={width}
|
|
59
|
+
backgroundColor={active && !disabled ? hoverBg() : undefined}
|
|
60
|
+
onMouseDown={(event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
|
|
61
|
+
event?.preventDefault?.();
|
|
62
|
+
event?.stopPropagation?.();
|
|
63
|
+
if (!disabled) onChange?.(!checked);
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<Text fg={fg} attributes={active ? TextAttributes.BOLD : 0}>
|
|
67
|
+
{`${active ? "> " : " "}[${marker}] ${visibleLabel}`}
|
|
68
|
+
</Text>
|
|
69
|
+
{description ? (
|
|
70
|
+
<Text fg={colors.textMuted} wrapText width={descriptionWidth}>
|
|
71
|
+
{description}
|
|
72
|
+
</Text>
|
|
73
|
+
) : null}
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -37,6 +37,15 @@ function getInitialChoiceIndex(choices: ChoiceDialogChoice[], selectedChoiceId:
|
|
|
37
37
|
return selectedIndex >= 0 ? selectedIndex : 0;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function choiceDialogWidth(title: string, choices: ChoiceDialogChoice[]): number {
|
|
41
|
+
const contentWidth = Math.max(
|
|
42
|
+
title.length,
|
|
43
|
+
...choices.map((choice) => choice.label.length + (choice.detail ? choice.detail.length + 4 : 0)),
|
|
44
|
+
...choices.map((choice) => getChoiceDescription(choice).length),
|
|
45
|
+
);
|
|
46
|
+
return Math.max(34, Math.min(76, contentWidth + 4));
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
export function ChoiceDialog({
|
|
41
50
|
resolve,
|
|
42
51
|
title,
|
|
@@ -57,6 +66,7 @@ export function ChoiceDialog({
|
|
|
57
66
|
detail: choice.detail,
|
|
58
67
|
disabled: choice.disabled,
|
|
59
68
|
})), [choices]);
|
|
69
|
+
const width = useMemo(() => choiceDialogWidth(title, choices), [choices, title]);
|
|
60
70
|
|
|
61
71
|
useEffect(() => {
|
|
62
72
|
setIndex((current) => clampChoiceIndex(current, choices.length));
|
|
@@ -81,19 +91,21 @@ export function ChoiceDialog({
|
|
|
81
91
|
});
|
|
82
92
|
|
|
83
93
|
return (
|
|
84
|
-
<DialogFrame title={title} footer={footer}>
|
|
85
|
-
<Box flexDirection="column">
|
|
94
|
+
<DialogFrame title={title} footer={footer} showTitleDivider={false}>
|
|
95
|
+
<Box flexDirection="column" width={width}>
|
|
86
96
|
<ListView
|
|
87
97
|
items={items}
|
|
88
98
|
selectedIndex={selectedIndex}
|
|
89
99
|
bgColor={bgColor}
|
|
90
100
|
emptyMessage="No choices."
|
|
101
|
+
rowGap={0}
|
|
102
|
+
surface="framed"
|
|
91
103
|
selectOnHover
|
|
92
104
|
onSelect={setIndex}
|
|
93
105
|
onActivate={(_, nextIndex) => activateChoice(choices[nextIndex])}
|
|
94
106
|
/>
|
|
95
107
|
<Box height={1} />
|
|
96
|
-
<Text fg={colors.textDim}>{getChoiceDescription(selectedChoice)}</Text>
|
|
108
|
+
<Text fg={colors.textDim} wrapText width={width}>{getChoiceDescription(selectedChoice)}</Text>
|
|
97
109
|
</Box>
|
|
98
110
|
</DialogFrame>
|
|
99
111
|
);
|
|
@@ -11,7 +11,7 @@ export interface DialogFrameProps {
|
|
|
11
11
|
showTitleDivider?: boolean;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export function DialogFrame({ title, children, footer, showTitleDivider =
|
|
14
|
+
export function DialogFrame({ title, children, footer, showTitleDivider = false }: DialogFrameProps) {
|
|
15
15
|
useThemeColors();
|
|
16
16
|
const HostDialogFrame = useUiHost().DialogFrame as ComponentType<DialogFrameProps> | undefined;
|
|
17
17
|
if (HostDialogFrame) {
|
|
@@ -6,6 +6,8 @@ export { DataTable } from "./data-table";
|
|
|
6
6
|
export type { DataTableCell, DataTableColumn, DataTableProps } from "./data-table";
|
|
7
7
|
|
|
8
8
|
export { Button } from "./button";
|
|
9
|
+
export { Checkbox } from "./checkbox";
|
|
10
|
+
export type { CheckboxProps } from "./checkbox";
|
|
9
11
|
export { ShortcutHint } from "./shortcut-hint";
|
|
10
12
|
|
|
11
13
|
export { MultiSelectDialogButton, MultiSelectDialogContent } from "./multi-select/dialog";
|
|
@@ -17,6 +17,7 @@ export interface MessageComposerProps {
|
|
|
17
17
|
terminalBottomInset?: number;
|
|
18
18
|
onFocusRequest?: () => void;
|
|
19
19
|
onInput?: (value: string) => void;
|
|
20
|
+
onCursorChange?: () => void;
|
|
20
21
|
onSubmit?: () => void;
|
|
21
22
|
keyBindings?: Array<Record<string, unknown>>;
|
|
22
23
|
wrapText?: boolean;
|
|
@@ -52,6 +53,7 @@ export function MessageComposer({
|
|
|
52
53
|
terminalBottomInset = 0,
|
|
53
54
|
onFocusRequest,
|
|
54
55
|
onInput,
|
|
56
|
+
onCursorChange,
|
|
55
57
|
onSubmit,
|
|
56
58
|
keyBindings,
|
|
57
59
|
wrapText = false,
|
|
@@ -91,6 +93,7 @@ export function MessageComposer({
|
|
|
91
93
|
terminalBottomInset={terminalBottomInset}
|
|
92
94
|
onFocusRequest={onFocusRequest}
|
|
93
95
|
onInput={onInput}
|
|
96
|
+
onCursorChange={onCursorChange}
|
|
94
97
|
onSubmit={onSubmit}
|
|
95
98
|
keyBindings={keyBindings}
|
|
96
99
|
wrapText={wrapText}
|
|
@@ -149,6 +152,7 @@ export function MessageComposer({
|
|
|
149
152
|
focusedBackgroundColor={colors.bg}
|
|
150
153
|
cursorColor={colors.textBright}
|
|
151
154
|
onInput={handleInput}
|
|
155
|
+
onCursorChange={onCursorChange}
|
|
152
156
|
keyBindings={keyBindings}
|
|
153
157
|
onSubmit={onSubmit}
|
|
154
158
|
wrapText={wrapText}
|
|
@@ -17,6 +17,7 @@ export interface NativeSelectProps {
|
|
|
17
17
|
value: string;
|
|
18
18
|
options: NativeSelectOption[];
|
|
19
19
|
width?: number | string;
|
|
20
|
+
height?: number;
|
|
20
21
|
includeUnsetOption?: boolean;
|
|
21
22
|
selectRef?: (element: NativeSelectElement | null) => void;
|
|
22
23
|
onFocus?: () => void;
|
|
@@ -41,6 +42,7 @@ export function NativeSelect({
|
|
|
41
42
|
value,
|
|
42
43
|
options,
|
|
43
44
|
width = 184,
|
|
45
|
+
height = 28,
|
|
44
46
|
includeUnsetOption = false,
|
|
45
47
|
selectRef,
|
|
46
48
|
onFocus,
|
|
@@ -49,7 +51,7 @@ export function NativeSelect({
|
|
|
49
51
|
const hasCurrentValue = options.some((option) => option.value === value);
|
|
50
52
|
const style: CSSProperties = {
|
|
51
53
|
width,
|
|
52
|
-
height
|
|
54
|
+
height,
|
|
53
55
|
color: colors.text,
|
|
54
56
|
backgroundColor: blendHex(colors.panel, colors.textBright, 0.06),
|
|
55
57
|
border: `1px solid ${colors.border}`,
|
|
@@ -66,7 +68,7 @@ export function NativeSelect({
|
|
|
66
68
|
|
|
67
69
|
return (
|
|
68
70
|
<Box
|
|
69
|
-
height=
|
|
71
|
+
height={`${height}px`}
|
|
70
72
|
flexDirection="row"
|
|
71
73
|
alignItems="center"
|
|
72
74
|
onMouseDown={(event: any) => {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MarketDataCoordinatorEvents } from "./events";
|
|
3
|
+
|
|
4
|
+
const waitMicrotask = () => Promise.resolve();
|
|
5
|
+
const waitTimer = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
6
|
+
|
|
7
|
+
describe("MarketDataCoordinatorEvents", () => {
|
|
8
|
+
test("coalesces version bumps before notifying external-store listeners", async () => {
|
|
9
|
+
const events = new MarketDataCoordinatorEvents();
|
|
10
|
+
const calls: number[] = [];
|
|
11
|
+
events.subscribeKeys(["quote:AMD"], () => {
|
|
12
|
+
calls.push(events.getKeysVersion(["quote:AMD"]));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
events.bump("quote:AMD");
|
|
16
|
+
events.bump("quote:AMD");
|
|
17
|
+
|
|
18
|
+
expect(events.getKeysVersion(["quote:AMD"])).toBe(0);
|
|
19
|
+
expect(calls).toEqual([]);
|
|
20
|
+
|
|
21
|
+
await waitMicrotask();
|
|
22
|
+
expect(events.getKeysVersion(["quote:AMD"])).toBe(1);
|
|
23
|
+
expect(calls).toEqual([]);
|
|
24
|
+
|
|
25
|
+
await waitTimer();
|
|
26
|
+
expect(calls).toEqual([1]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("delivers bumps scheduled during notification in a later notification pass", async () => {
|
|
30
|
+
const events = new MarketDataCoordinatorEvents();
|
|
31
|
+
const order: string[] = [];
|
|
32
|
+
events.subscribeKeys(["quote:AMD"], () => {
|
|
33
|
+
order.push("AMD");
|
|
34
|
+
events.bump("quote:NVDA");
|
|
35
|
+
});
|
|
36
|
+
events.subscribeKeys(["quote:NVDA"], () => {
|
|
37
|
+
order.push("NVDA");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
events.bump("quote:AMD");
|
|
41
|
+
await waitMicrotask();
|
|
42
|
+
await waitTimer();
|
|
43
|
+
expect(order).toEqual(["AMD"]);
|
|
44
|
+
|
|
45
|
+
await waitMicrotask();
|
|
46
|
+
await waitTimer();
|
|
47
|
+
expect(order).toEqual(["AMD", "NVDA"]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -4,6 +4,8 @@ export class MarketDataCoordinatorEvents {
|
|
|
4
4
|
private version = 0;
|
|
5
5
|
private pendingVersionBump = false;
|
|
6
6
|
private pendingChangedKeys = new Set<string>();
|
|
7
|
+
private pendingNotify = false;
|
|
8
|
+
private pendingListeners = new Set<() => void>();
|
|
7
9
|
private readonly listeners = new Set<() => void>();
|
|
8
10
|
private readonly keyListeners = new Map<string, Set<() => void>>();
|
|
9
11
|
private readonly keyVersions = new Map<string, number>();
|
|
@@ -60,15 +62,34 @@ export class MarketDataCoordinatorEvents {
|
|
|
60
62
|
for (const key of changedKeys) {
|
|
61
63
|
this.keyVersions.set(key, (this.keyVersions.get(key) ?? 0) + 1);
|
|
62
64
|
}
|
|
63
|
-
const
|
|
65
|
+
for (const listener of this.listeners) {
|
|
66
|
+
this.pendingListeners.add(listener);
|
|
67
|
+
}
|
|
64
68
|
for (const key of changedKeys) {
|
|
65
69
|
for (const listener of this.keyListeners.get(key) ?? []) {
|
|
66
|
-
|
|
70
|
+
this.pendingListeners.add(listener);
|
|
67
71
|
}
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
for (const listener of keyListeners) listener();
|
|
74
|
+
this.scheduleNotify();
|
|
72
75
|
}, { changedKeyCount: changedKeys.size });
|
|
73
76
|
}
|
|
77
|
+
|
|
78
|
+
private scheduleNotify(): void {
|
|
79
|
+
if (this.pendingNotify) return;
|
|
80
|
+
this.pendingNotify = true;
|
|
81
|
+
setTimeout(() => this.flushNotify(), 0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private flushNotify(): void {
|
|
85
|
+
this.pendingNotify = false;
|
|
86
|
+
const listeners = [...this.pendingListeners];
|
|
87
|
+
this.pendingListeners.clear();
|
|
88
|
+
for (const listener of listeners) {
|
|
89
|
+
listener();
|
|
90
|
+
}
|
|
91
|
+
if (this.pendingListeners.size > 0) {
|
|
92
|
+
this.scheduleNotify();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
74
95
|
}
|
|
@@ -347,6 +347,229 @@ describe("MarketDataCoordinator", () => {
|
|
|
347
347
|
expect(coordinator.getQuoteEntry(instrument).data?.price).toBe(412.5);
|
|
348
348
|
});
|
|
349
349
|
|
|
350
|
+
it("reconciles stream quotes with cached provider day references before a snapshot loads", () => {
|
|
351
|
+
let streamed: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
|
|
352
|
+
const provider = createProvider({
|
|
353
|
+
getCachedFinancialsForTargets: () => new Map([[
|
|
354
|
+
"VICR",
|
|
355
|
+
{
|
|
356
|
+
quote: {
|
|
357
|
+
symbol: "VICR",
|
|
358
|
+
providerId: "gloomberb-cloud",
|
|
359
|
+
price: 292.83,
|
|
360
|
+
currency: "USD",
|
|
361
|
+
previousClose: 282.95,
|
|
362
|
+
change: 9.88,
|
|
363
|
+
changePercent: 3.49,
|
|
364
|
+
lastUpdated: Date.parse("2026-07-06T16:54:30Z"),
|
|
365
|
+
marketState: "REGULAR",
|
|
366
|
+
dataSource: "live",
|
|
367
|
+
},
|
|
368
|
+
quoteContributions: {
|
|
369
|
+
"gloomberb-cloud": {
|
|
370
|
+
symbol: "VICR",
|
|
371
|
+
providerId: "gloomberb-cloud",
|
|
372
|
+
price: 292.83,
|
|
373
|
+
currency: "USD",
|
|
374
|
+
previousClose: 380.07,
|
|
375
|
+
change: -87.24,
|
|
376
|
+
changePercent: -22.95,
|
|
377
|
+
lastUpdated: Date.parse("2026-07-06T16:54:30Z"),
|
|
378
|
+
marketState: "REGULAR",
|
|
379
|
+
dataSource: "live",
|
|
380
|
+
},
|
|
381
|
+
yahoo: {
|
|
382
|
+
symbol: "VICR",
|
|
383
|
+
providerId: "yahoo",
|
|
384
|
+
price: 294.39,
|
|
385
|
+
currency: "USD",
|
|
386
|
+
previousClose: 282.95,
|
|
387
|
+
change: 11.44,
|
|
388
|
+
changePercent: 4.04,
|
|
389
|
+
lastUpdated: Date.parse("2026-07-06T16:45:37Z"),
|
|
390
|
+
marketState: "REGULAR",
|
|
391
|
+
dataSource: "delayed",
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
annualStatements: [],
|
|
395
|
+
quarterlyStatements: [],
|
|
396
|
+
priceHistory: [],
|
|
397
|
+
},
|
|
398
|
+
]]),
|
|
399
|
+
subscribeQuotes: (_targets, onQuote) => {
|
|
400
|
+
streamed = onQuote as typeof streamed;
|
|
401
|
+
return () => {};
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
405
|
+
const instrument = {
|
|
406
|
+
symbol: "VICR",
|
|
407
|
+
exchange: "NASDAQ",
|
|
408
|
+
brokerId: "ibkr",
|
|
409
|
+
brokerInstanceId: "ibkr-work",
|
|
410
|
+
instrument: {
|
|
411
|
+
brokerId: "ibkr",
|
|
412
|
+
brokerInstanceId: "ibkr-work",
|
|
413
|
+
conId: 275759,
|
|
414
|
+
symbol: "VICR",
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
coordinator.subscribeQuotes([{ instrument }]);
|
|
419
|
+
const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
|
|
420
|
+
if (!onStreamed) throw new Error("expected streaming callback");
|
|
421
|
+
onStreamed(
|
|
422
|
+
{
|
|
423
|
+
symbol: "VICR",
|
|
424
|
+
exchange: "NASDAQ",
|
|
425
|
+
context: {
|
|
426
|
+
brokerId: "ibkr",
|
|
427
|
+
brokerInstanceId: "ibkr-work",
|
|
428
|
+
instrument: instrument.instrument,
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
symbol: "VICR",
|
|
433
|
+
providerId: "gloomberb-cloud",
|
|
434
|
+
price: 293.07,
|
|
435
|
+
currency: "USD",
|
|
436
|
+
previousClose: 380.07,
|
|
437
|
+
change: -87,
|
|
438
|
+
changePercent: -22.89,
|
|
439
|
+
lastUpdated: Date.parse("2026-07-06T17:17:00Z"),
|
|
440
|
+
marketState: "REGULAR",
|
|
441
|
+
dataSource: "live",
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const quote = coordinator.getQuoteEntry(instrument).data;
|
|
446
|
+
expect(quote?.price).toBe(293.07);
|
|
447
|
+
expect(quote?.previousClose).toBe(282.95);
|
|
448
|
+
expect(quote?.changePercent).toBeCloseTo(((293.07 - 282.95) / 282.95) * 100, 10);
|
|
449
|
+
expect(quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("reconciles batch quote loads with cached provider day references before a snapshot loads", async () => {
|
|
453
|
+
const provider = createProvider({
|
|
454
|
+
getCachedFinancialsForTargets: () => new Map([[
|
|
455
|
+
"VICR",
|
|
456
|
+
{
|
|
457
|
+
quote: {
|
|
458
|
+
symbol: "VICR",
|
|
459
|
+
providerId: "gloomberb-cloud",
|
|
460
|
+
price: 292.83,
|
|
461
|
+
currency: "USD",
|
|
462
|
+
previousClose: 282.95,
|
|
463
|
+
change: 9.88,
|
|
464
|
+
changePercent: 3.49,
|
|
465
|
+
lastUpdated: Date.parse("2026-07-06T16:54:30Z"),
|
|
466
|
+
marketState: "REGULAR",
|
|
467
|
+
dataSource: "live",
|
|
468
|
+
},
|
|
469
|
+
quoteContributions: {
|
|
470
|
+
"gloomberb-cloud": {
|
|
471
|
+
symbol: "VICR",
|
|
472
|
+
providerId: "gloomberb-cloud",
|
|
473
|
+
price: 292.83,
|
|
474
|
+
currency: "USD",
|
|
475
|
+
previousClose: 380.07,
|
|
476
|
+
change: -87.24,
|
|
477
|
+
changePercent: -22.95,
|
|
478
|
+
lastUpdated: Date.parse("2026-07-06T16:54:30Z"),
|
|
479
|
+
marketState: "REGULAR",
|
|
480
|
+
dataSource: "live",
|
|
481
|
+
},
|
|
482
|
+
yahoo: {
|
|
483
|
+
symbol: "VICR",
|
|
484
|
+
providerId: "yahoo",
|
|
485
|
+
price: 294.39,
|
|
486
|
+
currency: "USD",
|
|
487
|
+
previousClose: 282.95,
|
|
488
|
+
change: 11.44,
|
|
489
|
+
changePercent: 4.04,
|
|
490
|
+
lastUpdated: Date.parse("2026-07-06T16:45:37Z"),
|
|
491
|
+
marketState: "REGULAR",
|
|
492
|
+
dataSource: "delayed",
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
annualStatements: [],
|
|
496
|
+
quarterlyStatements: [],
|
|
497
|
+
priceHistory: [],
|
|
498
|
+
},
|
|
499
|
+
]]),
|
|
500
|
+
getQuotesBatch: async (targets) => targets.map((target) => ({
|
|
501
|
+
target,
|
|
502
|
+
quote: {
|
|
503
|
+
symbol: target.symbol,
|
|
504
|
+
providerId: "gloomberb-cloud",
|
|
505
|
+
price: 293.07,
|
|
506
|
+
currency: "USD",
|
|
507
|
+
previousClose: 380.07,
|
|
508
|
+
change: -87,
|
|
509
|
+
changePercent: -22.89,
|
|
510
|
+
lastUpdated: Date.parse("2026-07-06T17:17:00Z"),
|
|
511
|
+
marketState: "REGULAR",
|
|
512
|
+
dataSource: "live",
|
|
513
|
+
},
|
|
514
|
+
})),
|
|
515
|
+
});
|
|
516
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
517
|
+
const instrument = {
|
|
518
|
+
symbol: "VICR",
|
|
519
|
+
exchange: "NASDAQ",
|
|
520
|
+
brokerId: "ibkr",
|
|
521
|
+
brokerInstanceId: "ibkr-work",
|
|
522
|
+
instrument: {
|
|
523
|
+
brokerId: "ibkr",
|
|
524
|
+
brokerInstanceId: "ibkr-work",
|
|
525
|
+
conId: 275759,
|
|
526
|
+
symbol: "VICR",
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
await coordinator.loadQuotesBatch([instrument], { forceRefresh: true });
|
|
531
|
+
|
|
532
|
+
const quote = coordinator.getQuoteEntry(instrument).data;
|
|
533
|
+
expect(quote?.price).toBe(293.07);
|
|
534
|
+
expect(quote?.previousClose).toBe(282.95);
|
|
535
|
+
expect(quote?.changePercent).toBeCloseTo(((293.07 - 282.95) / 282.95) * 100, 10);
|
|
536
|
+
expect(quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("preserves requested quote stream routes", () => {
|
|
540
|
+
let subscribedTargets: QuoteSubscriptionTarget[] = [];
|
|
541
|
+
const provider = createProvider({
|
|
542
|
+
subscribeQuotes: (targets) => {
|
|
543
|
+
subscribedTargets = targets;
|
|
544
|
+
return () => {};
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
548
|
+
|
|
549
|
+
coordinator.subscribeQuotes([{
|
|
550
|
+
instrument: {
|
|
551
|
+
symbol: "AAPL",
|
|
552
|
+
exchange: "NASDAQ",
|
|
553
|
+
brokerId: "ibkr",
|
|
554
|
+
brokerInstanceId: "ibkr-work",
|
|
555
|
+
},
|
|
556
|
+
priority: { route: "provider", surface: "portfolio", visible: true },
|
|
557
|
+
}]);
|
|
558
|
+
|
|
559
|
+
expect(subscribedTargets).toEqual([{
|
|
560
|
+
symbol: "AAPL",
|
|
561
|
+
exchange: "NASDAQ",
|
|
562
|
+
route: "provider",
|
|
563
|
+
context: {
|
|
564
|
+
brokerId: "ibkr",
|
|
565
|
+
brokerInstanceId: "ibkr-work",
|
|
566
|
+
instrument: null,
|
|
567
|
+
},
|
|
568
|
+
surface: "portfolio",
|
|
569
|
+
visible: true,
|
|
570
|
+
}]);
|
|
571
|
+
});
|
|
572
|
+
|
|
350
573
|
it("routes manual-resolution chart requests through the resolution-aware provider path", async () => {
|
|
351
574
|
let requested: { range: string; resolution: string } | null = null;
|
|
352
575
|
const provider = createProvider({
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
resolveEntryData,
|
|
20
20
|
toMarketDataContext,
|
|
21
21
|
} from "../selectors";
|
|
22
|
+
import { resolveTickerFinancialsQuoteState } from "../quotes/resolution";
|
|
23
|
+
import { hasLikelyQuoteUnitMismatch } from "../../utils/currency-units";
|
|
22
24
|
import { normalizePriceHistory } from "../../utils/price-history";
|
|
23
25
|
import {
|
|
24
26
|
createBaselineChartRequest,
|
|
@@ -218,6 +220,7 @@ export class MarketDataCoordinator {
|
|
|
218
220
|
options,
|
|
219
221
|
quoteStore: this.quoteStore,
|
|
220
222
|
runSingleFlight: (key, task) => this.runSingleFlight(key, task),
|
|
223
|
+
resolveQuote: (targetInstrument, quote) => this.resolveIncomingQuote(targetInstrument, quote),
|
|
221
224
|
});
|
|
222
225
|
}
|
|
223
226
|
|
|
@@ -231,6 +234,7 @@ export class MarketDataCoordinator {
|
|
|
231
234
|
options,
|
|
232
235
|
quoteStore: this.quoteStore,
|
|
233
236
|
runSingleFlight: (key, task) => this.runSingleFlight(key, task),
|
|
237
|
+
resolveQuote: (targetInstrument, quote) => this.resolveIncomingQuote(targetInstrument, quote),
|
|
234
238
|
});
|
|
235
239
|
}
|
|
236
240
|
|
|
@@ -377,10 +381,43 @@ export class MarketDataCoordinator {
|
|
|
377
381
|
private applyStreamQuote(instrument: InstrumentRef, quote: Quote): void {
|
|
378
382
|
const key = buildQuoteKey(instrument);
|
|
379
383
|
const current = this.quoteStore.get(key);
|
|
380
|
-
|
|
384
|
+
const resolvedQuote = this.resolveIncomingQuote(instrument, quote);
|
|
385
|
+
if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, resolvedQuote)) return;
|
|
381
386
|
const receivedAt = Date.now();
|
|
382
|
-
const attempts = [createAttempt(
|
|
383
|
-
this.quoteStore.set(key, readyQuoteEntry(current, { ...
|
|
387
|
+
const attempts = [createAttempt(resolvedQuote.providerId ?? this.dataProvider.id, receivedAt, "success")];
|
|
388
|
+
this.quoteStore.set(key, readyQuoteEntry(current, { ...resolvedQuote, receivedAt }, resolvedQuote.providerId ?? this.dataProvider.id, attempts));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private resolveIncomingQuote(instrument: InstrumentRef, quote: Quote): Quote {
|
|
392
|
+
const snapshot = resolveEntryData(this.snapshotStore.get(buildSnapshotKey(instrument)));
|
|
393
|
+
if (snapshot?.quote) {
|
|
394
|
+
if (hasLikelyQuoteUnitMismatch(snapshot.quote, quote)) return quote;
|
|
395
|
+
if (
|
|
396
|
+
typeof snapshot.quote.lastUpdated === "number"
|
|
397
|
+
&& typeof quote.lastUpdated === "number"
|
|
398
|
+
&& quote.lastUpdated < snapshot.quote.lastUpdated
|
|
399
|
+
) {
|
|
400
|
+
return quote;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const cachedFinancials = snapshot ?? this.readCachedFinancialsForInstrument(instrument);
|
|
404
|
+
return resolveTickerFinancialsQuoteState(cachedFinancials, quote)?.quote ?? quote;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private readCachedFinancialsForInstrument(instrument: InstrumentRef): TickerFinancials | null {
|
|
408
|
+
if (!this.dataProvider.getCachedFinancialsForTargets) return null;
|
|
409
|
+
const result = this.dataProvider.getCachedFinancialsForTargets([{
|
|
410
|
+
symbol: instrument.symbol,
|
|
411
|
+
exchange: instrument.exchange,
|
|
412
|
+
brokerId: instrument.brokerId,
|
|
413
|
+
brokerInstanceId: instrument.brokerInstanceId,
|
|
414
|
+
instrument: instrument.instrument ?? null,
|
|
415
|
+
}], {
|
|
416
|
+
allowExpired: true,
|
|
417
|
+
includeStaleQuotes: true,
|
|
418
|
+
});
|
|
419
|
+
if (result instanceof Promise) return null;
|
|
420
|
+
return result.get(instrument.symbol.trim().toUpperCase()) ?? null;
|
|
384
421
|
}
|
|
385
422
|
}
|
|
386
423
|
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
readyQuoteEntry,
|
|
16
16
|
} from "./entries";
|
|
17
17
|
|
|
18
|
-
export type QuoteSubscriptionPriority = Pick<QuoteSubscriptionTarget, "surface" | "visible" | "selected" | "weight">;
|
|
18
|
+
export type QuoteSubscriptionPriority = Pick<QuoteSubscriptionTarget, "route" | "surface" | "visible" | "selected" | "weight">;
|
|
19
19
|
|
|
20
20
|
export interface QuoteSubscriptionEntry {
|
|
21
21
|
target: QuoteSubscriptionTarget;
|
|
@@ -123,6 +123,7 @@ interface LoadQuoteEntryOptions {
|
|
|
123
123
|
options?: CoordinatorLoadOptions;
|
|
124
124
|
quoteStore: QueryStore<Quote>;
|
|
125
125
|
runSingleFlight: CoordinatorSingleFlight;
|
|
126
|
+
resolveQuote?: (instrument: InstrumentRef, quote: Quote) => Quote;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
export async function loadQuoteEntry({
|
|
@@ -131,6 +132,7 @@ export async function loadQuoteEntry({
|
|
|
131
132
|
options = {},
|
|
132
133
|
quoteStore,
|
|
133
134
|
runSingleFlight,
|
|
135
|
+
resolveQuote,
|
|
134
136
|
}: LoadQuoteEntryOptions): Promise<QueryEntry<Quote>> {
|
|
135
137
|
const key = buildQuoteKey(instrument);
|
|
136
138
|
const flightKey = options.forceRefresh ? `${key}|refresh` : key;
|
|
@@ -146,9 +148,10 @@ export async function loadQuoteEntry({
|
|
|
146
148
|
cacheMode: options.forceRefresh ? "refresh" : "default",
|
|
147
149
|
},
|
|
148
150
|
);
|
|
149
|
-
const
|
|
151
|
+
const resolvedQuote = resolveQuote?.(instrument, quote) ?? quote;
|
|
152
|
+
const source = resolvedQuote.providerId ?? dataProvider.id;
|
|
150
153
|
const attempts = [createAttempt(source, startedAt, "success")];
|
|
151
|
-
return quoteStore.update(key, (current) => readyQuoteEntry(current,
|
|
154
|
+
return quoteStore.update(key, (current) => readyQuoteEntry(current, resolvedQuote, source, attempts));
|
|
152
155
|
} catch (error) {
|
|
153
156
|
const classified = classifyError(error);
|
|
154
157
|
const attempt = createAttempt(dataProvider.id, startedAt, EXPECTED_EMPTY.test(classified.message) ? "empty" : "fatal_error", classified.reasonCode, classified.message);
|
|
@@ -163,6 +166,7 @@ interface LoadQuoteBatchEntriesOptions {
|
|
|
163
166
|
options?: CoordinatorLoadOptions;
|
|
164
167
|
quoteStore: QueryStore<Quote>;
|
|
165
168
|
runSingleFlight: CoordinatorSingleFlight;
|
|
169
|
+
resolveQuote?: (instrument: InstrumentRef, quote: Quote) => Quote;
|
|
166
170
|
}
|
|
167
171
|
|
|
168
172
|
export async function loadQuoteBatchEntries({
|
|
@@ -171,6 +175,7 @@ export async function loadQuoteBatchEntries({
|
|
|
171
175
|
options = {},
|
|
172
176
|
quoteStore,
|
|
173
177
|
runSingleFlight,
|
|
178
|
+
resolveQuote,
|
|
174
179
|
}: LoadQuoteBatchEntriesOptions): Promise<QueryEntry<Quote>[]> {
|
|
175
180
|
const uniqueInstruments = [...new Map(instruments.map((instrument) => [buildQuoteKey(instrument), instrument] as const)).values()];
|
|
176
181
|
const results = new Map<string, QueryEntry<Quote>>();
|
|
@@ -195,9 +200,10 @@ export async function loadQuoteBatchEntries({
|
|
|
195
200
|
const instrument = misses[index];
|
|
196
201
|
if (!instrument || !item.quote) return;
|
|
197
202
|
const key = buildQuoteKey(instrument);
|
|
198
|
-
const
|
|
203
|
+
const quote = resolveQuote?.(instrument, item.quote) ?? item.quote;
|
|
204
|
+
const source = quote.providerId ?? dataProvider.id;
|
|
199
205
|
const attempts = [createAttempt(source, Date.now(), "success")];
|
|
200
|
-
results.set(key, quoteStore.update(key, (current) => readyQuoteEntry(current,
|
|
206
|
+
results.set(key, quoteStore.update(key, (current) => readyQuoteEntry(current, quote, source, attempts)));
|
|
201
207
|
});
|
|
202
208
|
}
|
|
203
209
|
|
|
@@ -210,6 +216,7 @@ export async function loadQuoteBatchEntries({
|
|
|
210
216
|
options,
|
|
211
217
|
quoteStore,
|
|
212
218
|
runSingleFlight,
|
|
219
|
+
resolveQuote,
|
|
213
220
|
}));
|
|
214
221
|
}));
|
|
215
222
|
|
|
@@ -292,6 +299,7 @@ export class QuoteSubscriptionManager {
|
|
|
292
299
|
.sort(([left], [right]) => left.localeCompare(right));
|
|
293
300
|
const nextSignature = activeEntries.map(([key, entry]) => [
|
|
294
301
|
key,
|
|
302
|
+
entry.target.route ?? "",
|
|
295
303
|
entry.target.surface ?? "",
|
|
296
304
|
entry.target.visible ? "visible" : "",
|
|
297
305
|
entry.target.selected ? "selected" : "",
|