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
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "r
|
|
|
2
2
|
import type { SecFilingDocument, SecFilingItem } from "../types/data-provider";
|
|
3
3
|
import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../types/financials";
|
|
4
4
|
import type { TickerRecord } from "../types/ticker";
|
|
5
|
-
import type { ChartRequest, InstrumentRef, OptionsRequest, SecFilingsRequest } from "./request-types";
|
|
5
|
+
import type { ChartRequest, InstrumentRef, OptionsRequest, SecFilingsRequest, TickerInstrumentOptions } from "./request-types";
|
|
6
6
|
import { instrumentFromTicker } from "./request-types";
|
|
7
7
|
import { useAppActive } from "../state/app/activity";
|
|
8
8
|
import {
|
|
@@ -74,17 +74,17 @@ function stableCurrencyList(currencies: Array<string | null | undefined>): strin
|
|
|
74
74
|
)].sort((left, right) => left.localeCompare(right));
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
function buildTickerFinancialsMapKey(tickers: TickerRecord[]): string {
|
|
77
|
+
function buildTickerFinancialsMapKey(tickers: TickerRecord[], options: TickerInstrumentOptions = {}): string {
|
|
78
78
|
return tickers.map((ticker) => {
|
|
79
|
-
const instrument = ticker.metadata.
|
|
79
|
+
const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker, options);
|
|
80
80
|
return [
|
|
81
81
|
ticker.metadata.ticker,
|
|
82
82
|
ticker.metadata.exchange ?? "",
|
|
83
83
|
instrument?.brokerId ?? "",
|
|
84
84
|
instrument?.brokerInstanceId ?? "",
|
|
85
|
-
instrument?.conId ?? "",
|
|
86
|
-
instrument?.localSymbol ?? "",
|
|
87
|
-
instrument?.symbol ?? "",
|
|
85
|
+
instrument?.instrument?.conId ?? "",
|
|
86
|
+
instrument?.instrument?.localSymbol ?? "",
|
|
87
|
+
instrument?.instrument?.symbol ?? "",
|
|
88
88
|
].join("|");
|
|
89
89
|
}).join("::");
|
|
90
90
|
}
|
|
@@ -122,10 +122,10 @@ export function useTickerFinancials(symbol: string | null | undefined, ticker: T
|
|
|
122
122
|
return financials;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function buildTickerFinancialsKeys(tickers: TickerRecord[]): string[] {
|
|
125
|
+
function buildTickerFinancialsKeys(tickers: TickerRecord[], options: TickerInstrumentOptions = {}): string[] {
|
|
126
126
|
const keys: string[] = [];
|
|
127
127
|
for (const ticker of tickers) {
|
|
128
|
-
const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
|
|
128
|
+
const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker, options);
|
|
129
129
|
if (!instrument) continue;
|
|
130
130
|
keys.push(
|
|
131
131
|
buildSnapshotKey(instrument),
|
|
@@ -136,17 +136,21 @@ function buildTickerFinancialsKeys(tickers: TickerRecord[]): string[] {
|
|
|
136
136
|
return keys;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
export function useTickerFinancialsMap(
|
|
139
|
+
export function useTickerFinancialsMap(
|
|
140
|
+
tickers: TickerRecord[],
|
|
141
|
+
options: TickerInstrumentOptions = {},
|
|
142
|
+
): Map<string, TickerFinancials> {
|
|
140
143
|
const coordinator = getSharedMarketDataCoordinator();
|
|
141
|
-
const
|
|
142
|
-
const
|
|
144
|
+
const optionsKey = options.portfolioId ?? "";
|
|
145
|
+
const tickerKey = buildTickerFinancialsMapKey(tickers, options);
|
|
146
|
+
const subscriptionKeys = useMemo(() => buildTickerFinancialsKeys(tickers, options), [optionsKey, tickerKey]);
|
|
143
147
|
const keysVersion = useCoordinatorKeysVersion(subscriptionKeys);
|
|
144
148
|
|
|
145
149
|
return useMemo(() => {
|
|
146
150
|
if (!coordinator) return new Map<string, TickerFinancials>();
|
|
147
151
|
const result = new Map<string, TickerFinancials>();
|
|
148
152
|
for (const ticker of tickers) {
|
|
149
|
-
const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
|
|
153
|
+
const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker, options);
|
|
150
154
|
if (!instrument) continue;
|
|
151
155
|
const financials = coordinator.getTickerFinancialsSync(instrument);
|
|
152
156
|
if (financials) {
|
|
@@ -154,7 +158,7 @@ export function useTickerFinancialsMap(tickers: TickerRecord[]): Map<string, Tic
|
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
return result;
|
|
157
|
-
}, [coordinator, keysVersion, tickerKey, subscriptionKeys]);
|
|
161
|
+
}, [coordinator, keysVersion, optionsKey, tickerKey, subscriptionKeys]);
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
export function useQuoteEntry(symbol: string | null | undefined, ticker: TickerRecord | null | undefined): QueryEntry<Quote> | null {
|
|
@@ -5,7 +5,33 @@ const US_EXTENDED_HOURS_EXCHANGES = new Set(["NASDAQ", "NYSE", "AMEX", "ARCA", "
|
|
|
5
5
|
const ALWAYS_OPEN_EXCHANGES = new Set(["CCC"]);
|
|
6
6
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
7
7
|
const OVERNIGHT_CLOSE_MAX_AGE_MS = 20 * 60 * 60 * 1000;
|
|
8
|
+
const REGULAR_OPEN_MINUTES: Record<string, number> = {
|
|
9
|
+
NASDAQ: 9 * 60 + 30,
|
|
10
|
+
NYSE: 9 * 60 + 30,
|
|
11
|
+
ARCA: 9 * 60 + 30,
|
|
12
|
+
AMEX: 9 * 60 + 30,
|
|
13
|
+
BATS: 9 * 60 + 30,
|
|
14
|
+
TSX: 9 * 60 + 30,
|
|
15
|
+
TSXV: 9 * 60 + 30,
|
|
16
|
+
CSE: 9 * 60 + 30,
|
|
17
|
+
FWB2: 8 * 60,
|
|
18
|
+
XETRA: 8 * 60,
|
|
19
|
+
LSE: 8 * 60,
|
|
20
|
+
EPA: 9 * 60,
|
|
21
|
+
AMS: 9 * 60,
|
|
22
|
+
BRU: 9 * 60,
|
|
23
|
+
LIS: 8 * 60,
|
|
24
|
+
BIT: 9 * 60,
|
|
25
|
+
SFB: 9 * 60,
|
|
26
|
+
HEL: 10 * 60,
|
|
27
|
+
CPH: 9 * 60,
|
|
28
|
+
JPX: 9 * 60,
|
|
29
|
+
HKEX: 9 * 60 + 30,
|
|
30
|
+
TWSE: 9 * 60,
|
|
31
|
+
NSE: 9 * 60 + 15,
|
|
32
|
+
};
|
|
8
33
|
const exchangeLocalDateFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
34
|
+
const exchangeLocalTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
9
35
|
const usSessionFormatter = new Intl.DateTimeFormat("en-US", {
|
|
10
36
|
timeZone: "America/New_York",
|
|
11
37
|
weekday: "short",
|
|
@@ -34,6 +60,20 @@ function getExchangeLocalDateFormatter(timeZone: string): Intl.DateTimeFormat {
|
|
|
34
60
|
return formatter;
|
|
35
61
|
}
|
|
36
62
|
|
|
63
|
+
function getExchangeLocalTimeFormatter(timeZone: string): Intl.DateTimeFormat {
|
|
64
|
+
let formatter = exchangeLocalTimeFormatters.get(timeZone);
|
|
65
|
+
if (!formatter) {
|
|
66
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
67
|
+
timeZone,
|
|
68
|
+
hour: "2-digit",
|
|
69
|
+
minute: "2-digit",
|
|
70
|
+
hourCycle: "h23",
|
|
71
|
+
});
|
|
72
|
+
exchangeLocalTimeFormatters.set(timeZone, formatter);
|
|
73
|
+
}
|
|
74
|
+
return formatter;
|
|
75
|
+
}
|
|
76
|
+
|
|
37
77
|
function exchangeLocalDate(exchange: string, timestampMs: number): string | null {
|
|
38
78
|
const timeZone = EXCHANGE_TIME_ZONES[canonicalExchange(exchange)];
|
|
39
79
|
if (!timeZone) return null;
|
|
@@ -46,6 +86,23 @@ function exchangeLocalDate(exchange: string, timestampMs: number): string | null
|
|
|
46
86
|
return `${year}-${month}-${day}`;
|
|
47
87
|
}
|
|
48
88
|
|
|
89
|
+
function exchangeLocalMinuteOfDay(exchange: string, timestampMs: number): number | null {
|
|
90
|
+
const timeZone = EXCHANGE_TIME_ZONES[canonicalExchange(exchange)];
|
|
91
|
+
if (!timeZone) return null;
|
|
92
|
+
|
|
93
|
+
const parts = getExchangeLocalTimeFormatter(timeZone).formatToParts(new Date(timestampMs));
|
|
94
|
+
const hour = Number(parts.find((part) => part.type === "hour")?.value);
|
|
95
|
+
const minute = Number(parts.find((part) => part.type === "minute")?.value);
|
|
96
|
+
return Number.isFinite(hour) && Number.isFinite(minute) ? hour * 60 + minute : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isBeforeKnownRegularOpen(exchange: string, timestampMs: number): boolean {
|
|
100
|
+
const canonical = canonicalExchange(exchange);
|
|
101
|
+
const openMinute = REGULAR_OPEN_MINUTES[canonical];
|
|
102
|
+
const localMinute = exchangeLocalMinuteOfDay(canonical, timestampMs);
|
|
103
|
+
return openMinute != null && localMinute != null && localMinute < openMinute;
|
|
104
|
+
}
|
|
105
|
+
|
|
49
106
|
function isoLocalDateToUtcDay(date: string): number | null {
|
|
50
107
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
51
108
|
if (!match) return null;
|
|
@@ -122,7 +179,7 @@ function isTimestampStaleForExchangeSessionUnsafe(
|
|
|
122
179
|
if (!timestampDate || !currentDate || timestampDate === currentDate) return false;
|
|
123
180
|
|
|
124
181
|
if (ALWAYS_OPEN_EXCHANGES.has(canonical)) return true;
|
|
125
|
-
if (marketState === "REGULAR") return true;
|
|
182
|
+
if (marketState === "REGULAR" && !isBeforeKnownRegularOpen(canonical, now)) return true;
|
|
126
183
|
|
|
127
184
|
if (isUsExtendedHoursExchange(canonical)) {
|
|
128
185
|
const session = usSessionState(now);
|
|
@@ -132,7 +132,55 @@ describe("quote freshness", () => {
|
|
|
132
132
|
).toBe(false);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
test("
|
|
135
|
+
test("allows prior-day regular-session quotes before the local exchange reopens", () => {
|
|
136
|
+
expect(
|
|
137
|
+
isQuoteStaleForCurrentSession(
|
|
138
|
+
quote({
|
|
139
|
+
symbol: "NEO.TO",
|
|
140
|
+
currency: "CAD",
|
|
141
|
+
lastUpdated: Date.parse("2026-07-06T20:00:00Z"),
|
|
142
|
+
listingExchangeName: "TOR",
|
|
143
|
+
exchangeName: "TOR",
|
|
144
|
+
marketState: "REGULAR",
|
|
145
|
+
}),
|
|
146
|
+
Date.parse("2026-07-07T12:10:00Z"),
|
|
147
|
+
),
|
|
148
|
+
).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("allows non-US premarket labels without active-session prices", () => {
|
|
152
|
+
expect(
|
|
153
|
+
isQuoteStaleForCurrentSession(
|
|
154
|
+
quote({
|
|
155
|
+
symbol: "NEO.TO",
|
|
156
|
+
currency: "CAD",
|
|
157
|
+
lastUpdated: Date.parse("2026-07-07T12:10:00Z"),
|
|
158
|
+
listingExchangeName: "TOR",
|
|
159
|
+
exchangeName: "TOR",
|
|
160
|
+
marketState: "PRE",
|
|
161
|
+
}),
|
|
162
|
+
Date.parse("2026-07-07T12:10:30Z"),
|
|
163
|
+
),
|
|
164
|
+
).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("rejects US premarket labels without active-session prices", () => {
|
|
168
|
+
expect(
|
|
169
|
+
isQuoteStaleForCurrentSession(
|
|
170
|
+
quote({
|
|
171
|
+
symbol: "ALAB",
|
|
172
|
+
currency: "USD",
|
|
173
|
+
lastUpdated: Date.parse("2026-07-07T12:10:00Z"),
|
|
174
|
+
listingExchangeName: "NASDAQ",
|
|
175
|
+
exchangeName: "NASDAQ",
|
|
176
|
+
marketState: "PRE",
|
|
177
|
+
}),
|
|
178
|
+
Date.parse("2026-07-07T12:10:30Z"),
|
|
179
|
+
),
|
|
180
|
+
).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("treats prior-date regular-session quotes as stale after the local exchange reopens", () => {
|
|
136
184
|
expect(
|
|
137
185
|
isQuoteStaleForCurrentSession(
|
|
138
186
|
quote({
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { Quote } from "../../types/financials";
|
|
2
|
+
import { canonicalExchange } from "../../utils/exchanges";
|
|
2
3
|
import { isTimestampStaleForExchangeSession } from "../market/freshness";
|
|
3
4
|
|
|
5
|
+
const EXTENDED_HOURS_EXCHANGES = new Set(["NASDAQ", "NYSE", "AMEX", "ARCA", "BATS"]);
|
|
6
|
+
|
|
7
|
+
function isExtendedHoursExchange(quote: Quote): boolean {
|
|
8
|
+
return EXTENDED_HOURS_EXCHANGES.has(canonicalExchange(quote.listingExchangeName || quote.exchangeName));
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
function isQuoteMissingActiveSessionPrice(quote: Quote): boolean {
|
|
12
|
+
if (!isExtendedHoursExchange(quote)) return false;
|
|
5
13
|
if ((quote.marketState === "PRE" || quote.marketState === "PREPRE") && quote.preMarketPrice == null) {
|
|
6
14
|
return true;
|
|
7
15
|
}
|
|
@@ -68,6 +68,191 @@ describe("quote-resolution", () => {
|
|
|
68
68
|
expect(quote?.provenance?.session?.providerId).toBe("yahoo");
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
test("uses provider previous close with the live broker price for daily change", () => {
|
|
72
|
+
const now = Date.parse("2026-07-06T14:05:00Z");
|
|
73
|
+
const contributions: QuoteContributionMap = {
|
|
74
|
+
ibkr: {
|
|
75
|
+
symbol: "VICR",
|
|
76
|
+
providerId: "ibkr",
|
|
77
|
+
dataSource: "live",
|
|
78
|
+
price: 299.8,
|
|
79
|
+
currency: "USD",
|
|
80
|
+
change: -80.3,
|
|
81
|
+
changePercent: -21.12,
|
|
82
|
+
previousClose: 380.1,
|
|
83
|
+
lastUpdated: Date.parse("2026-07-06T14:04:58Z"),
|
|
84
|
+
receivedAt: Date.parse("2026-07-06T14:04:59Z"),
|
|
85
|
+
listingExchangeName: "NASDAQ",
|
|
86
|
+
routingExchangeName: "SMART",
|
|
87
|
+
sessionConfidence: "unknown",
|
|
88
|
+
},
|
|
89
|
+
yahoo: {
|
|
90
|
+
symbol: "VICR",
|
|
91
|
+
providerId: "yahoo",
|
|
92
|
+
dataSource: "delayed",
|
|
93
|
+
price: 299.74,
|
|
94
|
+
currency: "USD",
|
|
95
|
+
change: 16.79,
|
|
96
|
+
changePercent: 5.93,
|
|
97
|
+
previousClose: 282.95,
|
|
98
|
+
lastUpdated: Date.parse("2026-07-06T14:04:00Z"),
|
|
99
|
+
listingExchangeName: "NMS",
|
|
100
|
+
marketState: "REGULAR",
|
|
101
|
+
sessionConfidence: "derived",
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const quote = resolveCanonicalQuote(contributions, now).quote;
|
|
106
|
+
|
|
107
|
+
expect(quote?.providerId).toBe("ibkr");
|
|
108
|
+
expect(quote?.price).toBe(299.8);
|
|
109
|
+
expect(quote?.previousClose).toBe(282.95);
|
|
110
|
+
expect(quote?.change).toBeCloseTo(16.85, 10);
|
|
111
|
+
expect(quote?.changePercent).toBeCloseTo((16.85 / 282.95) * 100, 10);
|
|
112
|
+
expect(quote?.provenance?.price?.providerId).toBe("ibkr");
|
|
113
|
+
expect(quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("prefers fresher/provider-ranked non-live price candidates", () => {
|
|
117
|
+
const now = Date.parse("2026-07-07T11:25:00Z");
|
|
118
|
+
const cases: Array<{
|
|
119
|
+
contributions: QuoteContributionMap;
|
|
120
|
+
expectedPrice: number;
|
|
121
|
+
expectedProvider: string;
|
|
122
|
+
expectedChangePercent?: number;
|
|
123
|
+
expectedRoute?: string;
|
|
124
|
+
}> = [
|
|
125
|
+
{
|
|
126
|
+
contributions: {
|
|
127
|
+
ibkr: {
|
|
128
|
+
symbol: "LPK",
|
|
129
|
+
providerId: "ibkr",
|
|
130
|
+
dataSource: "delayed",
|
|
131
|
+
price: 17.75,
|
|
132
|
+
currency: "EUR",
|
|
133
|
+
change: -1.85,
|
|
134
|
+
changePercent: -9.44,
|
|
135
|
+
previousClose: 19.6,
|
|
136
|
+
lastUpdated: Date.parse("2026-07-07T11:02:00Z"),
|
|
137
|
+
receivedAt: Date.parse("2026-07-07T11:02:01Z"),
|
|
138
|
+
listingExchangeName: "IBIS",
|
|
139
|
+
routingExchangeName: "SMART",
|
|
140
|
+
sessionConfidence: "unknown",
|
|
141
|
+
},
|
|
142
|
+
yahoo: {
|
|
143
|
+
symbol: "LPK.DE",
|
|
144
|
+
providerId: "yahoo",
|
|
145
|
+
dataSource: "delayed",
|
|
146
|
+
price: 17.85,
|
|
147
|
+
currency: "EUR",
|
|
148
|
+
change: -1.75,
|
|
149
|
+
changePercent: -8.93,
|
|
150
|
+
previousClose: 19.6,
|
|
151
|
+
lastUpdated: Date.parse("2026-07-07T11:24:00Z"),
|
|
152
|
+
listingExchangeName: "GER",
|
|
153
|
+
marketState: "REGULAR",
|
|
154
|
+
sessionConfidence: "derived",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
expectedProvider: "yahoo",
|
|
158
|
+
expectedPrice: 17.85,
|
|
159
|
+
expectedChangePercent: ((17.85 - 19.6) / 19.6) * 100,
|
|
160
|
+
expectedRoute: "SMART",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
contributions: {
|
|
164
|
+
"gloomberb-cloud": {
|
|
165
|
+
symbol: "3HNX",
|
|
166
|
+
providerId: "gloomberb-cloud",
|
|
167
|
+
dataSource: "delayed",
|
|
168
|
+
price: 5.81,
|
|
169
|
+
currency: "GBP",
|
|
170
|
+
change: -1.58,
|
|
171
|
+
changePercent: -21.42,
|
|
172
|
+
previousClose: 7.39,
|
|
173
|
+
lastUpdated: Date.parse("2026-07-07T11:18:00Z"),
|
|
174
|
+
listingExchangeName: "LSE",
|
|
175
|
+
marketState: "REGULAR",
|
|
176
|
+
sessionConfidence: "derived",
|
|
177
|
+
},
|
|
178
|
+
yahoo: {
|
|
179
|
+
symbol: "3HNX.L",
|
|
180
|
+
providerId: "yahoo",
|
|
181
|
+
dataSource: "delayed",
|
|
182
|
+
price: 5.9775,
|
|
183
|
+
currency: "GBP",
|
|
184
|
+
change: -1.4125,
|
|
185
|
+
changePercent: -19.09,
|
|
186
|
+
previousClose: 7.39,
|
|
187
|
+
lastUpdated: Date.parse("2026-07-07T11:24:00Z"),
|
|
188
|
+
listingExchangeName: "LSE",
|
|
189
|
+
marketState: "REGULAR",
|
|
190
|
+
sessionConfidence: "derived",
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
expectedProvider: "yahoo",
|
|
194
|
+
expectedPrice: 5.9775,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
for (const scenario of cases) {
|
|
199
|
+
const quote = resolveCanonicalQuote(scenario.contributions, now).quote;
|
|
200
|
+
expect(quote?.providerId).toBe(scenario.expectedProvider);
|
|
201
|
+
expect(quote?.price).toBe(scenario.expectedPrice);
|
|
202
|
+
expect(quote?.provenance?.price?.providerId).toBe(scenario.expectedProvider);
|
|
203
|
+
if (scenario.expectedChangePercent != null) {
|
|
204
|
+
expect(quote?.changePercent).toBeCloseTo(scenario.expectedChangePercent, 10);
|
|
205
|
+
}
|
|
206
|
+
if (scenario.expectedRoute) {
|
|
207
|
+
expect(quote?.routingExchangeName).toBe(scenario.expectedRoute);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("prefers yahoo day reference fields over stale cloud previous close data", () => {
|
|
213
|
+
const now = Date.parse("2026-07-06T15:45:00Z");
|
|
214
|
+
const contributions: QuoteContributionMap = {
|
|
215
|
+
"gloomberb-cloud": {
|
|
216
|
+
symbol: "VICR",
|
|
217
|
+
providerId: "gloomberb-cloud",
|
|
218
|
+
dataSource: "delayed",
|
|
219
|
+
price: 299.8,
|
|
220
|
+
currency: "USD",
|
|
221
|
+
change: -80.27,
|
|
222
|
+
changePercent: -21.12,
|
|
223
|
+
previousClose: 380.07,
|
|
224
|
+
lastUpdated: Date.parse("2026-07-06T15:44:00Z"),
|
|
225
|
+
listingExchangeName: "NASDAQ",
|
|
226
|
+
marketState: "REGULAR",
|
|
227
|
+
sessionConfidence: "derived",
|
|
228
|
+
},
|
|
229
|
+
yahoo: {
|
|
230
|
+
symbol: "VICR",
|
|
231
|
+
providerId: "yahoo",
|
|
232
|
+
dataSource: "delayed",
|
|
233
|
+
price: 297.9,
|
|
234
|
+
currency: "USD",
|
|
235
|
+
change: 14.95,
|
|
236
|
+
changePercent: 5.28,
|
|
237
|
+
previousClose: 282.95,
|
|
238
|
+
lastUpdated: Date.parse("2026-07-06T15:41:00Z"),
|
|
239
|
+
listingExchangeName: "NMS",
|
|
240
|
+
marketState: "REGULAR",
|
|
241
|
+
sessionConfidence: "derived",
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const quote = resolveCanonicalQuote(contributions, now).quote;
|
|
246
|
+
|
|
247
|
+
expect(quote?.providerId).toBe("gloomberb-cloud");
|
|
248
|
+
expect(quote?.price).toBe(299.8);
|
|
249
|
+
expect(quote?.previousClose).toBe(282.95);
|
|
250
|
+
expect(quote?.change).toBeCloseTo(16.85, 10);
|
|
251
|
+
expect(quote?.changePercent).toBeCloseTo((16.85 / 282.95) * 100, 10);
|
|
252
|
+
expect(quote?.provenance?.price?.providerId).toBe("gloomberb-cloud");
|
|
253
|
+
expect(quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
|
|
254
|
+
});
|
|
255
|
+
|
|
71
256
|
test("prefers cloud session data over yahoo when confidence is tied", () => {
|
|
72
257
|
const now = Date.parse("2026-04-08T11:00:00Z");
|
|
73
258
|
const contributions: QuoteContributionMap = {
|
|
@@ -31,9 +31,6 @@ const PRICE_FIELD_KEYS = [
|
|
|
31
31
|
"symbol",
|
|
32
32
|
"price",
|
|
33
33
|
"currency",
|
|
34
|
-
"change",
|
|
35
|
-
"changePercent",
|
|
36
|
-
"previousClose",
|
|
37
34
|
"bid",
|
|
38
35
|
"ask",
|
|
39
36
|
"bidSize",
|
|
@@ -88,12 +85,39 @@ function providerKindRank(providerId?: string): number {
|
|
|
88
85
|
}
|
|
89
86
|
}
|
|
90
87
|
|
|
88
|
+
function dailyReferenceRank(providerId?: string): number {
|
|
89
|
+
switch (providerId) {
|
|
90
|
+
case "yahoo":
|
|
91
|
+
return 0;
|
|
92
|
+
case "gloomberb-cloud":
|
|
93
|
+
return 1;
|
|
94
|
+
case "ibkr":
|
|
95
|
+
return 2;
|
|
96
|
+
default:
|
|
97
|
+
return 3;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
function priceRank(quote: QuoteContribution): number {
|
|
92
102
|
if (quote.providerId === "ibkr" && quote.dataSource === "live") return 0;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function priceProviderTieRank(providerId?: string): number {
|
|
107
|
+
switch (providerId) {
|
|
108
|
+
case "gloomberb-cloud":
|
|
109
|
+
return 0;
|
|
110
|
+
case "yahoo":
|
|
111
|
+
return 1;
|
|
112
|
+
case "ibkr":
|
|
113
|
+
return 2;
|
|
114
|
+
default:
|
|
115
|
+
return 3;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function quoteUpdateTime(quote: QuoteContribution): number {
|
|
120
|
+
return quote.lastUpdated ?? quote.receivedAt ?? 0;
|
|
97
121
|
}
|
|
98
122
|
|
|
99
123
|
function sessionConfidenceRank(confidence?: SessionConfidence): number {
|
|
@@ -144,6 +168,61 @@ function pickField(
|
|
|
144
168
|
return undefined;
|
|
145
169
|
}
|
|
146
170
|
|
|
171
|
+
function finitePositiveNumber(value: unknown): value is number {
|
|
172
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildDailyReferenceCandidates(
|
|
176
|
+
candidates: QuoteContribution[],
|
|
177
|
+
priceProvider: QuoteContribution | undefined,
|
|
178
|
+
): QuoteContribution[] {
|
|
179
|
+
return [...candidates]
|
|
180
|
+
.filter((quote) => finitePositiveNumber(quote.previousClose))
|
|
181
|
+
.sort((left, right) => {
|
|
182
|
+
if (priceProvider) {
|
|
183
|
+
const leftMismatch = hasLikelyQuoteUnitMismatch(priceProvider, left) ? 1 : 0;
|
|
184
|
+
const rightMismatch = hasLikelyQuoteUnitMismatch(priceProvider, right) ? 1 : 0;
|
|
185
|
+
if (leftMismatch !== rightMismatch) return leftMismatch - rightMismatch;
|
|
186
|
+
}
|
|
187
|
+
const providerDelta = dailyReferenceRank(left.providerId) - dailyReferenceRank(right.providerId);
|
|
188
|
+
if (providerDelta !== 0) return providerDelta;
|
|
189
|
+
return (right.lastUpdated ?? 0) - (left.lastUpdated ?? 0);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function assignDailyChangeFields(
|
|
194
|
+
target: Partial<Quote>,
|
|
195
|
+
provenance: QuoteProvenance,
|
|
196
|
+
priceProvider: QuoteContribution | undefined,
|
|
197
|
+
candidates: QuoteContribution[],
|
|
198
|
+
): void {
|
|
199
|
+
const referenceProvider = buildDailyReferenceCandidates(candidates, priceProvider)[0];
|
|
200
|
+
if (!referenceProvider) {
|
|
201
|
+
pickField(target, provenance, "previousClose", candidates);
|
|
202
|
+
pickField(target, provenance, "change", candidates);
|
|
203
|
+
pickField(target, provenance, "changePercent", candidates);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
assignField(target, provenance, "previousClose", referenceProvider);
|
|
208
|
+
const price = target.price;
|
|
209
|
+
const previousClose = target.previousClose;
|
|
210
|
+
if (!finitePositiveNumber(price) || !finitePositiveNumber(previousClose)) {
|
|
211
|
+
pickField(target, provenance, "change", candidates);
|
|
212
|
+
pickField(target, provenance, "changePercent", candidates);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
target.change = price - previousClose;
|
|
217
|
+
target.changePercent = (target.change / previousClose) * 100;
|
|
218
|
+
provenance.fields ??= {};
|
|
219
|
+
const referenceProvenance = toProvenance(referenceProvider);
|
|
220
|
+
if (referenceProvenance) {
|
|
221
|
+
provenance.fields.change = referenceProvenance;
|
|
222
|
+
provenance.fields.changePercent = referenceProvenance;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
147
226
|
function buildAcceptedPriceCandidates(contributions: QuoteContribution[]): {
|
|
148
227
|
accepted: QuoteContribution[];
|
|
149
228
|
rejectedProviders: string[];
|
|
@@ -153,7 +232,13 @@ function buildAcceptedPriceCandidates(contributions: QuoteContribution[]): {
|
|
|
153
232
|
.sort((left, right) => {
|
|
154
233
|
const rankDelta = priceRank(left) - priceRank(right);
|
|
155
234
|
if (rankDelta !== 0) return rankDelta;
|
|
156
|
-
|
|
235
|
+
if (priceRank(left) > 0) {
|
|
236
|
+
const updateDelta = quoteUpdateTime(right) - quoteUpdateTime(left);
|
|
237
|
+
if (updateDelta !== 0) return updateDelta;
|
|
238
|
+
}
|
|
239
|
+
const providerDelta = priceProviderTieRank(left.providerId) - priceProviderTieRank(right.providerId);
|
|
240
|
+
if (providerDelta !== 0) return providerDelta;
|
|
241
|
+
return quoteUpdateTime(right) - quoteUpdateTime(left);
|
|
157
242
|
});
|
|
158
243
|
|
|
159
244
|
const accepted: QuoteContribution[] = [];
|
|
@@ -290,6 +375,7 @@ export function resolveCanonicalQuote(
|
|
|
290
375
|
if (field === "price") continue;
|
|
291
376
|
pickField(resolved, provenance, field, acceptedPriceCandidates);
|
|
292
377
|
}
|
|
378
|
+
assignDailyChangeFields(resolved, provenance, priceProvider, acceptedPriceCandidates);
|
|
293
379
|
provenance.price = toProvenance(priceProvider);
|
|
294
380
|
|
|
295
381
|
const sessionProvider = sessionCandidates[0];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { TickerRecord } from "../types/ticker";
|
|
3
|
-
import { quoteSubscriptionTargetFromTicker } from "./request-types";
|
|
3
|
+
import { instrumentFromTicker, quoteSubscriptionTargetFromTicker } from "./request-types";
|
|
4
4
|
|
|
5
5
|
function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRecord {
|
|
6
6
|
return {
|
|
@@ -21,6 +21,66 @@ function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRe
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
describe("quoteSubscriptionTargetFromTicker", () => {
|
|
24
|
+
test("selects the broker contract that belongs to the active portfolio", () => {
|
|
25
|
+
const ticker = makeTicker({
|
|
26
|
+
ticker: "VICR",
|
|
27
|
+
name: "Vicor",
|
|
28
|
+
portfolios: [
|
|
29
|
+
"broker:ibkr-live:DU111",
|
|
30
|
+
"broker:ibkr-coldstart:DU222",
|
|
31
|
+
],
|
|
32
|
+
positions: [
|
|
33
|
+
{
|
|
34
|
+
portfolio: "broker:ibkr-live:DU111",
|
|
35
|
+
shares: 170,
|
|
36
|
+
avgCost: 198,
|
|
37
|
+
currency: "USD",
|
|
38
|
+
broker: "ibkr",
|
|
39
|
+
brokerInstanceId: "ibkr-live",
|
|
40
|
+
brokerContractId: 275759,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
portfolio: "broker:ibkr-coldstart:DU222",
|
|
44
|
+
shares: 350,
|
|
45
|
+
avgCost: 290,
|
|
46
|
+
currency: "USD",
|
|
47
|
+
broker: "ibkr",
|
|
48
|
+
brokerInstanceId: "ibkr-coldstart",
|
|
49
|
+
brokerContractId: 275759,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
broker_contracts: [
|
|
53
|
+
{
|
|
54
|
+
brokerId: "ibkr",
|
|
55
|
+
brokerInstanceId: "ibkr-live",
|
|
56
|
+
conId: 275759,
|
|
57
|
+
symbol: "VICR",
|
|
58
|
+
localSymbol: "VICR",
|
|
59
|
+
exchange: "NASDAQ",
|
|
60
|
+
currency: "USD",
|
|
61
|
+
secType: "STK",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
brokerId: "ibkr",
|
|
65
|
+
brokerInstanceId: "ibkr-coldstart",
|
|
66
|
+
conId: 275759,
|
|
67
|
+
symbol: "VICR",
|
|
68
|
+
localSymbol: "VICR",
|
|
69
|
+
exchange: "NASDAQ",
|
|
70
|
+
currency: "USD",
|
|
71
|
+
secType: "STK",
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(instrumentFromTicker(ticker, "VICR", { portfolioId: "broker:ibkr-coldstart:DU222" })).toMatchObject({
|
|
77
|
+
symbol: "VICR",
|
|
78
|
+
brokerId: "ibkr",
|
|
79
|
+
brokerInstanceId: "ibkr-coldstart",
|
|
80
|
+
instrument: ticker.metadata.broker_contracts?.[1],
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
24
84
|
test("preserves broker contract context for streaming targets", () => {
|
|
25
85
|
const ticker = makeTicker({
|
|
26
86
|
broker_contracts: [{
|