gloomberb 0.9.7 → 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/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 +51 -1
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
- 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/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
|
@@ -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
|
}
|
|
@@ -312,6 +312,61 @@ describe("AssetDataRouter", () => {
|
|
|
312
312
|
expect(quote.price).toBe(123.45);
|
|
313
313
|
});
|
|
314
314
|
|
|
315
|
+
test("reconciles broker quote price with provider day reference fields", async () => {
|
|
316
|
+
const router = new AssetDataRouter({
|
|
317
|
+
...fallbackProvider,
|
|
318
|
+
id: "yahoo",
|
|
319
|
+
async getQuote() {
|
|
320
|
+
return {
|
|
321
|
+
symbol: "VICR",
|
|
322
|
+
providerId: "yahoo",
|
|
323
|
+
price: 299.74,
|
|
324
|
+
currency: "USD",
|
|
325
|
+
previousClose: 282.95,
|
|
326
|
+
change: 16.79,
|
|
327
|
+
changePercent: 5.93,
|
|
328
|
+
lastUpdated: Date.now(),
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
const broker: BrokerAdapter = {
|
|
333
|
+
id: "ibkr",
|
|
334
|
+
name: "IBKR",
|
|
335
|
+
configSchema: [],
|
|
336
|
+
async validate() {
|
|
337
|
+
return true;
|
|
338
|
+
},
|
|
339
|
+
async importPositions() {
|
|
340
|
+
return [];
|
|
341
|
+
},
|
|
342
|
+
async getQuote() {
|
|
343
|
+
return {
|
|
344
|
+
symbol: "VICR",
|
|
345
|
+
providerId: "ibkr",
|
|
346
|
+
price: 299.8,
|
|
347
|
+
currency: "USD",
|
|
348
|
+
previousClose: 380.1,
|
|
349
|
+
change: -80.3,
|
|
350
|
+
changePercent: -21.12,
|
|
351
|
+
lastUpdated: Date.now(),
|
|
352
|
+
dataSource: "live",
|
|
353
|
+
};
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
attachTestRegistry(router, { brokers: [["ibkr", broker]] });
|
|
358
|
+
setBrokerInstances(router, [brokerInstance()]);
|
|
359
|
+
|
|
360
|
+
const quote = await router.getQuote("VICR", "NASDAQ", { brokerId: "ibkr", brokerInstanceId: "ibkr-work" });
|
|
361
|
+
expect(quote.providerId).toBe("ibkr");
|
|
362
|
+
expect(quote.price).toBe(299.8);
|
|
363
|
+
expect(quote.previousClose).toBe(282.95);
|
|
364
|
+
expect(quote.change).toBeCloseTo(16.85, 5);
|
|
365
|
+
expect(quote.changePercent).toBeCloseTo(5.955, 3);
|
|
366
|
+
expect(quote.provenance?.price?.providerId).toBe("ibkr");
|
|
367
|
+
expect(quote.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
|
|
368
|
+
});
|
|
369
|
+
|
|
315
370
|
test("prefers broker options chains for broker-context targets", async () => {
|
|
316
371
|
const chain = {
|
|
317
372
|
underlyingSymbol: "AAPL",
|
|
@@ -820,6 +875,48 @@ describe("AssetDataRouter", () => {
|
|
|
820
875
|
unsubscribe();
|
|
821
876
|
});
|
|
822
877
|
|
|
878
|
+
test("drops empty zero provider stream quotes", () => {
|
|
879
|
+
const providerTargets: QuoteSubscriptionTarget[] = [];
|
|
880
|
+
const seenQuotes: Array<{ symbol: string; price: number }> = [];
|
|
881
|
+
const streamingProvider: DataProvider = {
|
|
882
|
+
...fallbackProvider,
|
|
883
|
+
id: "cloud",
|
|
884
|
+
name: "Cloud",
|
|
885
|
+
subscribeQuotes(targets, onQuote) {
|
|
886
|
+
providerTargets.push(...targets);
|
|
887
|
+
onQuote(targets[0]!, {
|
|
888
|
+
symbol: "HEXA B",
|
|
889
|
+
price: 0,
|
|
890
|
+
currency: "SEK",
|
|
891
|
+
change: 0,
|
|
892
|
+
changePercent: 0,
|
|
893
|
+
lastUpdated: Date.now(),
|
|
894
|
+
listingExchangeName: "SFB",
|
|
895
|
+
dataSource: "delayed",
|
|
896
|
+
});
|
|
897
|
+
return () => {};
|
|
898
|
+
},
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
const router = new AssetDataRouter(fallbackProvider, [streamingProvider]);
|
|
902
|
+
const unsubscribe = router.subscribeQuotes([{
|
|
903
|
+
symbol: "HEXA B",
|
|
904
|
+
exchange: "SFB",
|
|
905
|
+
route: "provider",
|
|
906
|
+
}], (_target, quote) => {
|
|
907
|
+
seenQuotes.push({ symbol: quote.symbol, price: quote.price });
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
expect(providerTargets).toEqual([{
|
|
911
|
+
symbol: "HEXA B",
|
|
912
|
+
exchange: "SFB",
|
|
913
|
+
route: "provider",
|
|
914
|
+
}]);
|
|
915
|
+
expect(seenQuotes).toEqual([]);
|
|
916
|
+
|
|
917
|
+
unsubscribe();
|
|
918
|
+
});
|
|
919
|
+
|
|
823
920
|
test("returns quote-only financials for option tickers when snapshots are unavailable", async () => {
|
|
824
921
|
const router = new AssetDataRouter({
|
|
825
922
|
...fallbackProvider,
|
|
@@ -1308,6 +1405,56 @@ describe("AssetDataRouter", () => {
|
|
|
1308
1405
|
expect(yahooCalls).toBe(1);
|
|
1309
1406
|
});
|
|
1310
1407
|
|
|
1408
|
+
test("fills unusable preferred provider financial quotes from a later provider", async () => {
|
|
1409
|
+
const cloudProvider: DataProvider = {
|
|
1410
|
+
...fallbackProvider,
|
|
1411
|
+
id: "cloud",
|
|
1412
|
+
name: "Cloud",
|
|
1413
|
+
priority: 100,
|
|
1414
|
+
async getTickerFinancials() {
|
|
1415
|
+
return makeFinancials({
|
|
1416
|
+
profile: { sector: "Industrials" },
|
|
1417
|
+
quote: makeQuote({
|
|
1418
|
+
symbol: "HY9H",
|
|
1419
|
+
price: 1295,
|
|
1420
|
+
change: -95,
|
|
1421
|
+
changePercent: -6.83,
|
|
1422
|
+
lastUpdated: Date.now() - 20 * 60_000,
|
|
1423
|
+
listingExchangeName: "FWB2",
|
|
1424
|
+
marketState: "REGULAR",
|
|
1425
|
+
}),
|
|
1426
|
+
});
|
|
1427
|
+
},
|
|
1428
|
+
};
|
|
1429
|
+
const yahooProvider: DataProvider = {
|
|
1430
|
+
...fallbackProvider,
|
|
1431
|
+
id: "yahoo",
|
|
1432
|
+
name: "Yahoo",
|
|
1433
|
+
priority: 1000,
|
|
1434
|
+
async getTickerFinancials() {
|
|
1435
|
+
return makeFinancials({
|
|
1436
|
+
quote: makeQuote({
|
|
1437
|
+
symbol: "HY9H.F",
|
|
1438
|
+
price: 1305,
|
|
1439
|
+
change: -85,
|
|
1440
|
+
changePercent: -6.12,
|
|
1441
|
+
lastUpdated: Date.now(),
|
|
1442
|
+
listingExchangeName: "FWB2",
|
|
1443
|
+
marketState: "REGULAR",
|
|
1444
|
+
}),
|
|
1445
|
+
});
|
|
1446
|
+
},
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
const router = new AssetDataRouter(yahooProvider, [cloudProvider]);
|
|
1450
|
+
const merged = await router.getTickerFinancials("HY9H", "FWB2", { cacheMode: "refresh" });
|
|
1451
|
+
|
|
1452
|
+
expect(merged.profile?.sector).toBe("Industrials");
|
|
1453
|
+
expect(merged.quote?.symbol).toBe("HY9H.F");
|
|
1454
|
+
expect(merged.quote?.price).toBe(1305);
|
|
1455
|
+
expect(merged.quote?.changePercent).toBe(-6.12);
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1311
1458
|
test("caches the preferred provider financial snapshot without merging fallback snapshots", async () => {
|
|
1312
1459
|
const dbPath = createTempDbPath("cached-provider-financial-preferred");
|
|
1313
1460
|
const persistence = new AppPersistence(dbPath);
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type { MarketDataRequestContext } from "../../types/data-provider";
|
|
2
2
|
import type { Quote, TickerFinancials } from "../../types/financials";
|
|
3
3
|
import { normalizeTickerFinancialsPriceHistory } from "../../utils/price-history";
|
|
4
|
-
import { isQuoteStaleForCurrentSession } from "../../market-data/quotes/freshness";
|
|
5
4
|
import { resolveTickerFinancialsQuoteState } from "../../market-data/quotes/resolution";
|
|
6
5
|
import { shouldLogProviderError } from "../provider-errors";
|
|
7
6
|
import {
|
|
7
|
+
dropUnusableProviderQuote,
|
|
8
8
|
hasDetailedStatementRows,
|
|
9
9
|
hasDeepStatementHistory,
|
|
10
10
|
hasStatementRows,
|
|
11
|
+
isProviderQuoteUsableForCurrentSession,
|
|
11
12
|
mergeMissingStatementArrays,
|
|
13
|
+
mergeFinancials,
|
|
12
14
|
} from "./financials";
|
|
13
15
|
import type { ProviderRouterCoreDeps, SourceResult } from "./route-types";
|
|
14
16
|
|
|
@@ -61,7 +63,8 @@ export class ProviderRouterPrimaryRoutes {
|
|
|
61
63
|
for (const provider of this.options.providersInPriorityOrder()) {
|
|
62
64
|
try {
|
|
63
65
|
const rawValue = await provider.getTickerFinancials(ticker, exchange, context);
|
|
64
|
-
const
|
|
66
|
+
const resolvedValue = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(rawValue));
|
|
67
|
+
const value = resolvedValue ? dropUnusableProviderQuote(resolvedValue, exchange) : null;
|
|
65
68
|
if (!value) continue;
|
|
66
69
|
const sourceKey = this.options.providerSourceKey(provider);
|
|
67
70
|
const cacheValue = primaryResult
|
|
@@ -84,6 +87,17 @@ export class ProviderRouterPrimaryRoutes {
|
|
|
84
87
|
if (hasDetailedStatementRows(value) && hasDeepStatementHistory(value)) return primaryResult;
|
|
85
88
|
continue;
|
|
86
89
|
}
|
|
90
|
+
if (!primaryResult.value.quote && value.quote) {
|
|
91
|
+
primaryResult = {
|
|
92
|
+
sourceKey: primaryResult.sourceKey,
|
|
93
|
+
value: mergeFinancials(primaryResult.value, {
|
|
94
|
+
annualStatements: [],
|
|
95
|
+
quarterlyStatements: [],
|
|
96
|
+
priceHistory: [],
|
|
97
|
+
quote: value.quote,
|
|
98
|
+
}) ?? primaryResult.value,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
87
101
|
if (hasStatementRows(value)) {
|
|
88
102
|
primaryResult = {
|
|
89
103
|
sourceKey: primaryResult.sourceKey,
|
|
@@ -143,7 +157,7 @@ export class ProviderRouterPrimaryRoutes {
|
|
|
143
157
|
for (const provider of this.options.providersInPriorityOrder()) {
|
|
144
158
|
try {
|
|
145
159
|
const quote = await provider.getQuote(ticker, exchange, context);
|
|
146
|
-
if (quote
|
|
160
|
+
if (!isProviderQuoteUsableForCurrentSession(quote, exchange)) continue;
|
|
147
161
|
const sourceKey = this.options.providerSourceKey(provider);
|
|
148
162
|
this.options.cacheResource(
|
|
149
163
|
"quote",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DataProvider, MarketDataRequestContext, QuoteSubscriptionTarget } from "../../types/data-provider";
|
|
2
2
|
import type { Quote } from "../../types/financials";
|
|
3
3
|
import type { BrokerCandidate } from "./brokers";
|
|
4
|
+
import { isProviderQuoteUsableForCurrentSession } from "./financials";
|
|
4
5
|
|
|
5
6
|
export interface ProviderRouterStreamingDeps {
|
|
6
7
|
providersInPriorityOrder(): DataProvider[];
|
|
@@ -67,7 +68,11 @@ export class ProviderRouterStreamingRoutes {
|
|
|
67
68
|
providerId: streamingProvider.id,
|
|
68
69
|
targetCount: providerTargets.length,
|
|
69
70
|
});
|
|
70
|
-
unsubscribers.push(streamingProvider.subscribeQuotes(providerTargets,
|
|
71
|
+
unsubscribers.push(streamingProvider.subscribeQuotes(providerTargets, (target, quote) => {
|
|
72
|
+
if (isProviderQuoteUsableForCurrentSession(quote, target.exchange)) {
|
|
73
|
+
onQuote(target, quote);
|
|
74
|
+
}
|
|
75
|
+
}));
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
if (unsubscribers.length === 0) {
|
|
@@ -200,6 +200,47 @@ describe("parseCompanyFactsFinancialStatements", () => {
|
|
|
200
200
|
},
|
|
201
201
|
]);
|
|
202
202
|
});
|
|
203
|
+
|
|
204
|
+
test("merges statement values from all configured tags for a field", () => {
|
|
205
|
+
const fact = (tag: string, unit: string, rows: unknown[]) => ({
|
|
206
|
+
[tag]: {
|
|
207
|
+
units: {
|
|
208
|
+
[unit]: rows,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
const quarterly = (end: string, val: number, frame: string) => ({
|
|
213
|
+
start: `${end.slice(0, 4)}-01-01`,
|
|
214
|
+
end,
|
|
215
|
+
val,
|
|
216
|
+
filed: end,
|
|
217
|
+
form: "10-Q",
|
|
218
|
+
fp: "Q1",
|
|
219
|
+
frame,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const statements = parseCompanyFactsFinancialStatements({
|
|
223
|
+
facts: {
|
|
224
|
+
"us-gaap": {
|
|
225
|
+
...fact("RevenueFromContractWithCustomerExcludingAssessedTax", "USD", [
|
|
226
|
+
quarterly("2026-03-31", 200, "CY2026Q1"),
|
|
227
|
+
]),
|
|
228
|
+
...fact("Revenues", "USD", [
|
|
229
|
+
quarterly("2025-03-31", 100, "CY2025Q1"),
|
|
230
|
+
]),
|
|
231
|
+
...fact("GrossProfit", "USD", [
|
|
232
|
+
quarterly("2025-03-31", 60, "CY2025Q1"),
|
|
233
|
+
quarterly("2026-03-31", 120, "CY2026Q1"),
|
|
234
|
+
]),
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(statements.quarterlyStatements).toEqual([
|
|
240
|
+
{ date: "2025-03-31", totalRevenue: 100, grossProfit: 60 },
|
|
241
|
+
{ date: "2026-03-31", totalRevenue: 200, grossProfit: 120 },
|
|
242
|
+
]);
|
|
243
|
+
});
|
|
203
244
|
});
|
|
204
245
|
|
|
205
246
|
describe("SecEdgarClient", () => {
|
|
@@ -403,6 +444,7 @@ describe("SecEdgarClient", () => {
|
|
|
403
444
|
|
|
404
445
|
const client = new SecEdgarClient();
|
|
405
446
|
const content = await client.getFilingContent({
|
|
447
|
+
form: "10-Q",
|
|
406
448
|
filingUrl: "https://www.sec.gov/Archives/edgar/data/320193/index.htm",
|
|
407
449
|
primaryDocumentUrl: "https://www.sec.gov/Archives/edgar/data/320193/primary-doc.htm",
|
|
408
450
|
});
|
package/src/sources/sec-edgar.ts
CHANGED
|
@@ -451,7 +451,7 @@ function setCompanyFactValue(
|
|
|
451
451
|
const selectionKey = `${date}:${String(field)}`;
|
|
452
452
|
if (compareCompanyFactsEntries(entry, selectedFacts.get(selectionKey)) < 0) return;
|
|
453
453
|
const row = rows.get(date) ?? { date };
|
|
454
|
-
(row as Record<string, unknown>)[field] = value;
|
|
454
|
+
(row as unknown as Record<string, unknown>)[field] = value;
|
|
455
455
|
rows.set(date, row);
|
|
456
456
|
selectedFacts.set(selectionKey, entry);
|
|
457
457
|
}
|
|
@@ -506,7 +506,6 @@ export function parseCompanyFactsFinancialStatements(payload: unknown): SecCompa
|
|
|
506
506
|
if (entries.length === 0) continue;
|
|
507
507
|
fillCompanyFactsStatementRows(annualRows, annualSelectedFacts, entries, field, "annual");
|
|
508
508
|
fillCompanyFactsStatementRows(quarterlyRows, quarterlySelectedFacts, entries, field, "quarterly");
|
|
509
|
-
break;
|
|
510
509
|
}
|
|
511
510
|
}
|
|
512
511
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { extractExtendedHoursPrices } from "./mappers";
|
|
3
|
+
import type { ChartResult } from "./types";
|
|
4
|
+
|
|
5
|
+
describe("Yahoo mappers", () => {
|
|
6
|
+
test("derives premarket change from the prior regular close", () => {
|
|
7
|
+
const meta: NonNullable<ChartResult["meta"]> = {
|
|
8
|
+
regularMarketPrice: 39.47,
|
|
9
|
+
chartPreviousClose: 29,
|
|
10
|
+
currentTradingPeriod: {
|
|
11
|
+
pre: { start: 100, end: 200 },
|
|
12
|
+
regular: { start: 300, end: 400 },
|
|
13
|
+
post: { start: 500, end: 600 },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const result = extractExtendedHoursPrices(
|
|
18
|
+
meta,
|
|
19
|
+
[100, 200],
|
|
20
|
+
[39, 39.47],
|
|
21
|
+
"PRE",
|
|
22
|
+
38,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(result.preMarketPrice).toBe(39.47);
|
|
26
|
+
expect(result.preMarketChange).toBeCloseTo(1.47, 8);
|
|
27
|
+
expect(result.preMarketChangePercent).toBeCloseTo(3.8684210526, 8);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -286,13 +286,14 @@ export function extractExtendedHoursPrices(
|
|
|
286
286
|
timestamps: number[],
|
|
287
287
|
closes: (number | null)[],
|
|
288
288
|
marketState: MarketState,
|
|
289
|
+
regularCloseOverride?: number,
|
|
289
290
|
): ExtendedHoursData {
|
|
290
291
|
const ctp = meta.currentTradingPeriod;
|
|
291
292
|
if (!ctp || !timestamps.length) return {};
|
|
292
293
|
|
|
293
294
|
const regStart = ctp.regular?.start ?? 0;
|
|
294
295
|
const regEnd = ctp.regular?.end ?? Infinity;
|
|
295
|
-
const regularClose = meta.regularMarketPrice ?? meta.chartPreviousClose;
|
|
296
|
+
const regularClose = regularCloseOverride ?? meta.regularMarketPrice ?? meta.chartPreviousClose;
|
|
296
297
|
|
|
297
298
|
if (marketState === "PRE") {
|
|
298
299
|
for (let i = timestamps.length - 1; i >= 0; i--) {
|
|
@@ -65,6 +65,7 @@ export async function fetchYahooExtendedHoursData(
|
|
|
65
65
|
http: YahooHttpClient,
|
|
66
66
|
symbol: string,
|
|
67
67
|
meta: NonNullable<ChartResult["meta"]>,
|
|
68
|
+
regularClose?: number,
|
|
68
69
|
): Promise<ExtendedHoursData> {
|
|
69
70
|
const marketState = deriveMarketState(meta);
|
|
70
71
|
if (marketState !== "PRE" && marketState !== "POST") return {};
|
|
@@ -80,7 +81,7 @@ export async function fetchYahooExtendedHoursData(
|
|
|
80
81
|
const result = data.chart?.result?.[0];
|
|
81
82
|
if (!result?.timestamp?.length) return {};
|
|
82
83
|
const closes = result.indicators?.quote?.[0]?.close || [];
|
|
83
|
-
return extractExtendedHoursPrices(meta, result.timestamp, closes, marketState);
|
|
84
|
+
return extractExtendedHoursPrices(meta, result.timestamp, closes, marketState, regularClose);
|
|
84
85
|
} catch {
|
|
85
86
|
return {};
|
|
86
87
|
}
|
|
@@ -42,6 +42,7 @@ interface YahooSnapshotLoaders {
|
|
|
42
42
|
fetchExtendedHoursData: (
|
|
43
43
|
symbol: string,
|
|
44
44
|
meta: NonNullable<ChartResult["meta"]>,
|
|
45
|
+
regularClose?: number,
|
|
45
46
|
) => Promise<ExtendedHoursData>;
|
|
46
47
|
fetchQuoteSupplement: (
|
|
47
48
|
symbol: string,
|
|
@@ -132,7 +133,12 @@ export async function loadYahooTickerFinancials(
|
|
|
132
133
|
const changePct = prev ? (change / prev) * 100 : 0;
|
|
133
134
|
|
|
134
135
|
const marketState = deriveMarketState(meta);
|
|
135
|
-
const
|
|
136
|
+
const extendedHoursBase = quoteSupplement.previousClose ?? prev;
|
|
137
|
+
const extHours = await loaders.fetchExtendedHoursData(
|
|
138
|
+
symbol,
|
|
139
|
+
meta,
|
|
140
|
+
extendedHoursBase == null ? undefined : extendedHoursBase * currencyDivisor,
|
|
141
|
+
);
|
|
136
142
|
if (currencyDivisor !== 1) {
|
|
137
143
|
normalizeExtendedHoursPrices(extHours, currencyDivisor);
|
|
138
144
|
}
|
|
@@ -207,7 +213,12 @@ export async function loadYahooQuote(
|
|
|
207
213
|
const change = prev != null ? price - prev : 0;
|
|
208
214
|
|
|
209
215
|
const marketState = deriveMarketState(meta);
|
|
210
|
-
const
|
|
216
|
+
const extendedHoursBase = quoteSupplement.previousClose ?? prev;
|
|
217
|
+
const extHours = await loaders.fetchExtendedHoursData(
|
|
218
|
+
symbol,
|
|
219
|
+
meta,
|
|
220
|
+
extendedHoursBase == null ? undefined : extendedHoursBase * currencyDivisor,
|
|
221
|
+
);
|
|
211
222
|
if (currencyDivisor !== 1) {
|
|
212
223
|
normalizeExtendedHoursPrices(extHours, currencyDivisor);
|
|
213
224
|
}
|
|
@@ -103,8 +103,12 @@ export class YahooFinanceClient implements DataProvider {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/** Fetch extended hours data using 1d intraday chart with pre/post market included */
|
|
106
|
-
private async fetchExtendedHoursData(
|
|
107
|
-
|
|
106
|
+
private async fetchExtendedHoursData(
|
|
107
|
+
symbol: string,
|
|
108
|
+
meta: NonNullable<ChartResult["meta"]>,
|
|
109
|
+
regularClose?: number,
|
|
110
|
+
) {
|
|
111
|
+
return fetchYahooExtendedHoursData(this.http, symbol, meta, regularClose);
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
private async fetchTimeseries(symbol: string, types: string[], period1 = "2010-01-01") {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildQuoteStreamSubscriptionKey,
|
|
4
|
+
normalizeQuoteStreamSubscriptionTarget,
|
|
5
|
+
} from "./quote-streaming";
|
|
3
6
|
|
|
4
7
|
describe("buildQuoteStreamSubscriptionKey", () => {
|
|
5
8
|
test("includes broker context so identical symbols can stream independently", () => {
|
|
@@ -28,4 +31,14 @@ describe("buildQuoteStreamSubscriptionKey", () => {
|
|
|
28
31
|
expect(workKey).not.toBe(personalKey);
|
|
29
32
|
});
|
|
30
33
|
|
|
34
|
+
test("keeps saved exchange aliases in stream targets", () => {
|
|
35
|
+
const target = normalizeQuoteStreamSubscriptionTarget({
|
|
36
|
+
symbol: "lpk",
|
|
37
|
+
exchange: "ibis",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(target?.symbol).toBe("LPK");
|
|
41
|
+
expect(target?.exchange).toBe("IBIS");
|
|
42
|
+
expect(buildQuoteStreamSubscriptionKey(target!)).toContain("LPK|IBIS");
|
|
43
|
+
});
|
|
31
44
|
});
|