gloomberb 0.6.1 → 0.7.1
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/patches/@opentui%2Fcore@0.1.90.patch +48 -24
- package/src/app.tsx +158 -57
- package/src/capabilities/factories.ts +3 -0
- package/src/cli/registry.test.ts +0 -1
- package/src/components/chart/chart-controller.ts +0 -1
- package/src/components/chart/chart-data.ts +1 -31
- package/src/components/chart/chart-renderer.ts +94 -12
- package/src/components/chart/chart-resolution.test.ts +0 -36
- package/src/components/chart/chart-resolution.ts +0 -27
- package/src/components/chart/chart.test.ts +60 -45
- package/src/components/chart/comparison-chart-data.test.ts +1 -5
- package/src/components/chart/comparison-chart-data.ts +1 -4
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +63 -39
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +30 -4
- package/src/components/chart/stock-chart.test.ts +0 -16
- package/src/components/chart/stock-chart.tsx +69 -41
- package/src/components/chart/time-axis-label.tsx +120 -0
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +182 -36
- package/src/components/command-bar/command-bar.tsx +943 -1016
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/confirm-body.tsx +51 -0
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +76 -0
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -20
- package/src/components/command-bar/workflow-ops.ts +4 -0
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/data-table-stack-view.test.tsx +39 -4
- package/src/components/data-table-stack-view.tsx +11 -104
- package/src/components/data-table-view.tsx +43 -79
- package/src/components/feed-data-table-stack-view.tsx +10 -17
- package/src/components/feed-stack-controller.ts +39 -0
- package/src/components/index.ts +5 -1
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +35 -4
- package/src/components/layout/header.tsx +12 -7
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-cycle-order.test.ts +30 -0
- package/src/components/layout/pane-cycle-order.ts +32 -0
- package/src/components/layout/pane-footer.tsx +11 -4
- package/src/components/layout/pane-header.tsx +5 -5
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +264 -22
- package/src/components/layout/shell.tsx +46 -8
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/onboarding/onboarding-wizard.tsx +1 -2
- package/src/components/pane-settings-dialog.tsx +12 -4
- package/src/components/pane-template-wizard.test.tsx +3 -4
- package/src/components/speedometer-gauge.test.tsx +45 -0
- package/src/components/speedometer-gauge.tsx +562 -0
- package/src/components/table-view-shared.tsx +218 -0
- package/src/components/ticker-list-table-view.tsx +57 -73
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/confirm-dialog.tsx +66 -0
- package/src/components/ui/data-table.tsx +30 -19
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/index.ts +0 -1
- package/src/components/ui/page-stack-view.tsx +39 -10
- package/src/components/ui/table-layout.test.ts +42 -0
- package/src/components/ui/table-layout.ts +49 -4
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +168 -21
- package/src/core/state/app-state.ts +10 -17
- package/src/market-data/coordinator.test.ts +76 -1
- package/src/market-data/coordinator.ts +218 -49
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -3
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
- package/src/plugins/builtin/ai/providers.ts +0 -17
- package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/ask-ai.test.tsx +89 -2
- package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
- package/src/plugins/builtin/broker-manager/index.tsx +19 -36
- package/src/plugins/builtin/chat-controller.test.ts +188 -1
- package/src/plugins/builtin/chat-controller.ts +537 -220
- package/src/plugins/builtin/chat.test.tsx +431 -13
- package/src/plugins/builtin/chat.tsx +1134 -227
- package/src/plugins/builtin/cloud-tweets.tsx +1 -1
- package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
- package/src/plugins/builtin/comparison-chart.tsx +11 -5
- package/src/plugins/builtin/correlation/index.tsx +1 -6
- package/src/plugins/builtin/debug.tsx +1 -3
- package/src/plugins/builtin/econ/index.tsx +1 -2
- package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
- package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
- package/src/plugins/builtin/fear-greed/index.tsx +587 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/help.tsx +2 -2
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +64 -29
- package/src/plugins/builtin/insider/index.tsx +22 -89
- package/src/plugins/builtin/market-movers/index.tsx +13 -19
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/index.tsx +41 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
- package/src/plugins/builtin/news.tsx +4 -16
- package/src/plugins/builtin/notes.tsx +19 -55
- package/src/plugins/builtin/options.test.tsx +58 -2
- package/src/plugins/builtin/options.tsx +110 -65
- package/src/plugins/builtin/plugin-groups.ts +2 -0
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +164 -33
- package/src/plugins/builtin/sec-filing-display.tsx +84 -0
- package/src/plugins/builtin/sec.tsx +13 -66
- package/src/plugins/builtin/sectors/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/builtin/world-indices/index.tsx +1 -1
- package/src/plugins/builtin/yield-curve/index.tsx +1 -1
- package/src/plugins/ibkr/broker-adapter.ts +1 -3
- package/src/plugins/ibkr/gateway-service-native.ts +1 -5
- package/src/plugins/ibkr/gateway-service.ts +1 -1
- package/src/plugins/ibkr/index.test.tsx +0 -2
- package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
- package/src/plugins/pane-manager.ts +0 -13
- package/src/plugins/plugin-runtime.test.tsx +40 -6
- package/src/plugins/plugin-runtime.tsx +20 -10
- package/src/plugins/prediction-markets/cli.ts +0 -1
- package/src/plugins/prediction-markets/controller-data.ts +0 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/prediction-markets/pane.test.tsx +3 -11
- package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
- package/src/plugins/registry.ts +13 -42
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +2 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +138 -101
- package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
- package/src/renderers/electrobun/view/input-host.tsx +74 -10
- package/src/renderers/electrobun/view/key-event.ts +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +329 -0
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +42 -0
- package/src/renderers/opentui/chart-surface.tsx +179 -0
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/renderers/opentui/start.tsx +48 -30
- package/src/renderers/opentui/test-utils.tsx +17 -0
- package/src/renderers/opentui/ui-host.tsx +5 -2
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -15
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +65 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.test.tsx +73 -0
- package/src/state/use-inline-tickers.ts +19 -10
- package/src/test-support/plugin-runtime.ts +3 -2
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +76 -6
- package/src/theme/theme-context.tsx +26 -0
- package/src/theme/themes.test.ts +23 -1
- package/src/theme/themes.ts +115 -0
- package/src/types/broker.ts +1 -1
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -2
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -3
- package/src/ui/host.tsx +7 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/back-navigation.ts +24 -1
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
- package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
|
@@ -195,6 +195,128 @@ describe("AssetDataRouter", () => {
|
|
|
195
195
|
expect(holders.holders[0]?.name).toBe("Vanguard Group Inc");
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
+
test("falls back to later analyst providers when rating targets are missing", async () => {
|
|
199
|
+
const cloudProvider: DataProvider = {
|
|
200
|
+
...fallbackProvider,
|
|
201
|
+
id: "cloud",
|
|
202
|
+
name: "Cloud",
|
|
203
|
+
priority: 100,
|
|
204
|
+
async getAnalystResearch(symbol) {
|
|
205
|
+
return {
|
|
206
|
+
providerId: "cloud",
|
|
207
|
+
symbol,
|
|
208
|
+
ratings: [{ date: "2026-05-01", firm: "Cloud Firm", action: "Raises", current: "Buy", prior: "Buy" }],
|
|
209
|
+
recommendations: [],
|
|
210
|
+
earningsEstimates: [],
|
|
211
|
+
revenueEstimates: [],
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
const yahooProvider: DataProvider = {
|
|
216
|
+
...fallbackProvider,
|
|
217
|
+
id: "yahoo",
|
|
218
|
+
name: "Yahoo",
|
|
219
|
+
priority: 1000,
|
|
220
|
+
async getAnalystResearch(symbol) {
|
|
221
|
+
return {
|
|
222
|
+
providerId: "yahoo",
|
|
223
|
+
symbol,
|
|
224
|
+
ratings: [{
|
|
225
|
+
date: "2026-05-01",
|
|
226
|
+
firm: "Yahoo Firm",
|
|
227
|
+
action: "Raises",
|
|
228
|
+
current: "Buy",
|
|
229
|
+
prior: "Buy",
|
|
230
|
+
currentPriceTarget: 680,
|
|
231
|
+
priorPriceTarget: 595,
|
|
232
|
+
}],
|
|
233
|
+
recommendations: [],
|
|
234
|
+
earningsEstimates: [],
|
|
235
|
+
revenueEstimates: [],
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const router = new AssetDataRouter(yahooProvider, [cloudProvider]);
|
|
241
|
+
const research = await router.getAnalystResearch("AAPL", "NASDAQ");
|
|
242
|
+
|
|
243
|
+
expect(research.providerId).toBe("yahoo");
|
|
244
|
+
expect(research.ratings[0]?.currentPriceTarget).toBe(680);
|
|
245
|
+
expect(research.ratings[0]?.priorPriceTarget).toBe(595);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("prefers cached analyst records with rating targets", async () => {
|
|
249
|
+
const dbPath = createTempDbPath("cached-analyst-targets");
|
|
250
|
+
const persistence = new AppPersistence(dbPath);
|
|
251
|
+
const cloudProvider: DataProvider = {
|
|
252
|
+
...fallbackProvider,
|
|
253
|
+
id: "cloud",
|
|
254
|
+
name: "Cloud",
|
|
255
|
+
priority: 100,
|
|
256
|
+
async getAnalystResearch(symbol) {
|
|
257
|
+
return {
|
|
258
|
+
providerId: "cloud",
|
|
259
|
+
symbol,
|
|
260
|
+
ratings: [{ date: "2026-05-01", firm: "Cloud Firm", action: "Raises", current: "Buy", prior: "Buy" }],
|
|
261
|
+
recommendations: [],
|
|
262
|
+
earningsEstimates: [],
|
|
263
|
+
revenueEstimates: [],
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
const yahooProvider: DataProvider = {
|
|
268
|
+
...fallbackProvider,
|
|
269
|
+
id: "yahoo",
|
|
270
|
+
name: "Yahoo",
|
|
271
|
+
priority: 1000,
|
|
272
|
+
async getAnalystResearch(symbol) {
|
|
273
|
+
return {
|
|
274
|
+
providerId: "yahoo",
|
|
275
|
+
symbol,
|
|
276
|
+
ratings: [{
|
|
277
|
+
date: "2026-05-01",
|
|
278
|
+
firm: "Yahoo Firm",
|
|
279
|
+
action: "Raises",
|
|
280
|
+
current: "Buy",
|
|
281
|
+
prior: "Buy",
|
|
282
|
+
currentPriceTarget: 680,
|
|
283
|
+
priorPriceTarget: 595,
|
|
284
|
+
}],
|
|
285
|
+
recommendations: [],
|
|
286
|
+
earningsEstimates: [],
|
|
287
|
+
revenueEstimates: [],
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
const unavailableProvider = (id: string, priority: number): DataProvider => ({
|
|
292
|
+
...fallbackProvider,
|
|
293
|
+
id,
|
|
294
|
+
name: id,
|
|
295
|
+
priority,
|
|
296
|
+
async getAnalystResearch() {
|
|
297
|
+
throw new Error(`${id} should not be fetched`);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const seedRouter = new AssetDataRouter(yahooProvider, [cloudProvider], persistence.resources);
|
|
303
|
+
await seedRouter.getAnalystResearch("AAPL", "NASDAQ");
|
|
304
|
+
|
|
305
|
+
const cachedRouter = new AssetDataRouter(
|
|
306
|
+
unavailableProvider("yahoo", 1000),
|
|
307
|
+
[unavailableProvider("cloud", 100)],
|
|
308
|
+
persistence.resources,
|
|
309
|
+
);
|
|
310
|
+
const research = await cachedRouter.getAnalystResearch("AAPL", "NASDAQ");
|
|
311
|
+
|
|
312
|
+
expect(research.providerId).toBe("yahoo");
|
|
313
|
+
expect(research.ratings[0]?.currentPriceTarget).toBe(680);
|
|
314
|
+
expect(research.ratings[0]?.priorPriceTarget).toBe(595);
|
|
315
|
+
} finally {
|
|
316
|
+
persistence.close();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
198
320
|
test("prefers broker quotes over fallback quotes", async () => {
|
|
199
321
|
const router = new AssetDataRouter(fallbackProvider);
|
|
200
322
|
const broker: BrokerAdapter = {
|
|
@@ -8,9 +8,11 @@ import type {
|
|
|
8
8
|
CachedFinancialsTarget,
|
|
9
9
|
DataProvider,
|
|
10
10
|
MarketDataRequestContext,
|
|
11
|
+
QuoteBatchResult,
|
|
11
12
|
QuoteSubscriptionTarget,
|
|
12
13
|
SearchRequestContext,
|
|
13
14
|
SecFilingItem,
|
|
15
|
+
TickerFinancialsBatchResult,
|
|
14
16
|
} from "../types/data-provider";
|
|
15
17
|
import type { CapabilityRouteSource } from "../types/capability-route-source";
|
|
16
18
|
import { routeSourcePriority } from "../types/capability-route-source";
|
|
@@ -134,10 +136,6 @@ function isIntradayRange(range: TimeRange): boolean {
|
|
|
134
136
|
return range === "1D" || range === "1W" || range === "1M" || range === "3M";
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
function hasMeaningfulFundamentals(data: TickerFinancials | null | undefined): boolean {
|
|
138
|
-
return !!data && Object.keys(data.fundamentals ?? {}).length > 0;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
139
|
function hasMeaningfulProfile(data: TickerFinancials | null | undefined): boolean {
|
|
142
140
|
return !!data && !!(
|
|
143
141
|
data.profile?.description
|
|
@@ -154,6 +152,16 @@ function hasAnalystResearchValue(data: AnalystResearchData): boolean {
|
|
|
154
152
|
|| data.revenueEstimates.length > 0;
|
|
155
153
|
}
|
|
156
154
|
|
|
155
|
+
function hasAnalystRatingPriceTargets(data: AnalystResearchData): boolean {
|
|
156
|
+
return data.ratings.some((rating) => (
|
|
157
|
+
rating.currentPriceTarget != null || rating.priorPriceTarget != null
|
|
158
|
+
));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isAnalystResearchMissingRatingTargets(data: AnalystResearchData): boolean {
|
|
162
|
+
return data.ratings.length > 0 && !hasAnalystRatingPriceTargets(data);
|
|
163
|
+
}
|
|
164
|
+
|
|
157
165
|
function hasCorporateActionsValue(data: CorporateActionsData): boolean {
|
|
158
166
|
return data.dividends.length > 0
|
|
159
167
|
|| data.splits.length > 0
|
|
@@ -349,6 +357,135 @@ export class AssetDataRouter implements DataProvider {
|
|
|
349
357
|
return results;
|
|
350
358
|
}
|
|
351
359
|
|
|
360
|
+
async getQuotesBatch(
|
|
361
|
+
targets: QuoteSubscriptionTarget[],
|
|
362
|
+
options: { forceRefresh?: boolean } = {},
|
|
363
|
+
): Promise<QuoteBatchResult[]> {
|
|
364
|
+
const forceRefresh = options.forceRefresh === true;
|
|
365
|
+
const results = new Array<QuoteBatchResult | null>(targets.length).fill(null);
|
|
366
|
+
const misses: Array<{ index: number; target: QuoteSubscriptionTarget }> = [];
|
|
367
|
+
|
|
368
|
+
targets.forEach((target, index) => {
|
|
369
|
+
const context = target.context;
|
|
370
|
+
const entityKey = this.getEntityKey(target.symbol, target.exchange, context?.instrument);
|
|
371
|
+
const variantKeys = this.getTickerVariantCandidates(target.exchange);
|
|
372
|
+
const sourceKeys = [
|
|
373
|
+
...this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate)),
|
|
374
|
+
...this.getProviderSourceKeys(),
|
|
375
|
+
];
|
|
376
|
+
const rawCached = this.selectCachedResource<Quote>("quote", entityKey, variantKeys, sourceKeys, false);
|
|
377
|
+
const cached = rawCached && !isQuoteStaleForCurrentSession(rawCached.value)
|
|
378
|
+
? rawCached
|
|
379
|
+
: null;
|
|
380
|
+
if (cached && !forceRefresh && !cached.stale) {
|
|
381
|
+
results[index] = { target, quote: cached.value };
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
misses.push({ index, target });
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const batchProvider = this.providersInPriorityOrder().find((provider) => provider.getQuotesBatch);
|
|
388
|
+
const providerMisses = misses.filter(({ target }) => !this.hasBrokerContext(target.context));
|
|
389
|
+
const providerIndexes = new Map<string, Array<{ index: number; target: QuoteSubscriptionTarget }>>();
|
|
390
|
+
if (batchProvider && providerMisses.length > 0) {
|
|
391
|
+
for (const entry of providerMisses) {
|
|
392
|
+
const key = this.quoteBatchKey(entry.target);
|
|
393
|
+
const bucket = providerIndexes.get(key) ?? [];
|
|
394
|
+
bucket.push(entry);
|
|
395
|
+
providerIndexes.set(key, bucket);
|
|
396
|
+
}
|
|
397
|
+
const uniqueTargets = [...providerIndexes.values()].map((bucket) => bucket[0]!.target);
|
|
398
|
+
const batchResults = await batchProvider.getQuotesBatch!(uniqueTargets, options).catch(() => []);
|
|
399
|
+
for (const item of batchResults) {
|
|
400
|
+
if (!item.quote) continue;
|
|
401
|
+
const key = this.quoteBatchKey(item.target);
|
|
402
|
+
const sourceKey = this.providerSourceKey(batchProvider);
|
|
403
|
+
for (const entry of providerIndexes.get(key) ?? []) {
|
|
404
|
+
const entityKey = this.getEntityKey(entry.target.symbol, entry.target.exchange, entry.target.context?.instrument);
|
|
405
|
+
const variantKey = this.getTickerVariantCandidates(entry.target.exchange)[0] ?? "";
|
|
406
|
+
this.cacheResource("quote", entityKey, variantKey, sourceKey, item.quote, this.resolveProviderPolicy("quote", batchProvider));
|
|
407
|
+
results[entry.index] = { target: entry.target, quote: item.quote };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
await Promise.all(misses.map(async ({ index, target }) => {
|
|
413
|
+
if (results[index]) return;
|
|
414
|
+
try {
|
|
415
|
+
const quote = await this.getQuote(target.symbol, target.exchange, {
|
|
416
|
+
...target.context,
|
|
417
|
+
cacheMode: forceRefresh ? "refresh" : "default",
|
|
418
|
+
});
|
|
419
|
+
results[index] = { target, quote };
|
|
420
|
+
} catch (error) {
|
|
421
|
+
results[index] = { target, quote: null, error };
|
|
422
|
+
}
|
|
423
|
+
}));
|
|
424
|
+
|
|
425
|
+
return results.map((result, index) => result ?? { target: targets[index]!, quote: null });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async getTickerFinancialsBatch(
|
|
429
|
+
targets: CachedFinancialsTarget[],
|
|
430
|
+
options: { forceRefresh?: boolean } = {},
|
|
431
|
+
): Promise<TickerFinancialsBatchResult[]> {
|
|
432
|
+
const forceRefresh = options.forceRefresh === true;
|
|
433
|
+
const results = new Array<TickerFinancialsBatchResult | null>(targets.length).fill(null);
|
|
434
|
+
const misses: Array<{ index: number; target: CachedFinancialsTarget }> = [];
|
|
435
|
+
|
|
436
|
+
targets.forEach((target, index) => {
|
|
437
|
+
const context = this.contextFromCachedTarget(target);
|
|
438
|
+
const cached = this.readCachedMergedFinancialsSelection(target.symbol, target.exchange, context, true);
|
|
439
|
+
if (cached.value && !forceRefresh) {
|
|
440
|
+
results[index] = { target, financials: cached.value };
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
misses.push({ index, target });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const batchProvider = this.providersInPriorityOrder().find((provider) => provider.getTickerFinancialsBatch);
|
|
447
|
+
const providerMisses = misses.filter(({ target }) => !this.hasCachedTargetBrokerContext(target));
|
|
448
|
+
const providerIndexes = new Map<string, Array<{ index: number; target: CachedFinancialsTarget }>>();
|
|
449
|
+
if (batchProvider && providerMisses.length > 0) {
|
|
450
|
+
for (const entry of providerMisses) {
|
|
451
|
+
const key = this.cachedFinancialsBatchKey(entry.target);
|
|
452
|
+
const bucket = providerIndexes.get(key) ?? [];
|
|
453
|
+
bucket.push(entry);
|
|
454
|
+
providerIndexes.set(key, bucket);
|
|
455
|
+
}
|
|
456
|
+
const uniqueTargets = [...providerIndexes.values()].map((bucket) => bucket[0]!.target);
|
|
457
|
+
const batchResults = await batchProvider.getTickerFinancialsBatch!(uniqueTargets, options).catch(() => []);
|
|
458
|
+
for (const item of batchResults) {
|
|
459
|
+
if (!item.financials) continue;
|
|
460
|
+
const key = this.cachedFinancialsBatchKey(item.target);
|
|
461
|
+
const value = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(item.financials));
|
|
462
|
+
if (!value) continue;
|
|
463
|
+
const sourceKey = this.providerSourceKey(batchProvider);
|
|
464
|
+
for (const entry of providerIndexes.get(key) ?? []) {
|
|
465
|
+
const entityKey = this.getEntityKey(entry.target.symbol, entry.target.exchange, entry.target.instrument ?? undefined);
|
|
466
|
+
const variantKey = this.getTickerVariantCandidates(entry.target.exchange)[0] ?? "";
|
|
467
|
+
this.cacheResource("financials", entityKey, variantKey, sourceKey, value, this.resolveProviderPolicy("financials", batchProvider));
|
|
468
|
+
results[entry.index] = { target: entry.target, financials: value };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
await Promise.all(misses.map(async ({ index, target }) => {
|
|
474
|
+
if (results[index]) return;
|
|
475
|
+
try {
|
|
476
|
+
const financials = await this.getTickerFinancials(target.symbol, target.exchange, {
|
|
477
|
+
...this.contextFromCachedTarget(target),
|
|
478
|
+
cacheMode: forceRefresh ? "refresh" : "default",
|
|
479
|
+
});
|
|
480
|
+
results[index] = { target, financials };
|
|
481
|
+
} catch (error) {
|
|
482
|
+
results[index] = { target, financials: null, error };
|
|
483
|
+
}
|
|
484
|
+
}));
|
|
485
|
+
|
|
486
|
+
return results.map((result, index) => result ?? { target: targets[index]!, financials: null });
|
|
487
|
+
}
|
|
488
|
+
|
|
352
489
|
async getTickerFinancials(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<TickerFinancials> {
|
|
353
490
|
const cached = this.readCachedMergedFinancialsSelection(ticker, exchange, context, false);
|
|
354
491
|
const forceRefresh = context?.cacheMode === "refresh";
|
|
@@ -522,9 +659,9 @@ export class AssetDataRouter implements DataProvider {
|
|
|
522
659
|
async getAnalystResearch(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<AnalystResearchData> {
|
|
523
660
|
const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
|
|
524
661
|
const variantKeys = this.getTickerVariantCandidates(exchange);
|
|
525
|
-
const cached = this.
|
|
662
|
+
const cached = this.selectCachedAnalystResearch(entityKey, variantKeys, this.getProviderSourceKeys(), false);
|
|
526
663
|
const forceRefresh = context?.cacheMode === "refresh";
|
|
527
|
-
if (cached && !forceRefresh) {
|
|
664
|
+
if (cached && !forceRefresh && !isAnalystResearchMissingRatingTargets(cached.value)) {
|
|
528
665
|
this.scheduleRevalidation(this.makeRevalidationKey("analystResearch", ticker, exchange, context), async () => {
|
|
529
666
|
await this.revalidateAnalystResearch(ticker, exchange, context);
|
|
530
667
|
});
|
|
@@ -880,7 +1017,17 @@ export class AssetDataRouter implements DataProvider {
|
|
|
880
1017
|
sourceKeys: string[],
|
|
881
1018
|
allowExpired: boolean,
|
|
882
1019
|
): CachedResourceRecord<T> | null {
|
|
883
|
-
return this.listCachedResources(kind, entityKey, variantKeys, sourceKeys, allowExpired)[0] ?? null;
|
|
1020
|
+
return this.listCachedResources<T>(kind, entityKey, variantKeys, sourceKeys, allowExpired)[0] ?? null;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
private selectCachedAnalystResearch(
|
|
1024
|
+
entityKey: string,
|
|
1025
|
+
variantKeys: string[],
|
|
1026
|
+
sourceKeys: string[],
|
|
1027
|
+
allowExpired: boolean,
|
|
1028
|
+
): CachedResourceRecord<AnalystResearchData> | null {
|
|
1029
|
+
const records = this.listCachedResources<AnalystResearchData>("analystResearch", entityKey, variantKeys, sourceKeys, allowExpired);
|
|
1030
|
+
return records.find((record) => !isAnalystResearchMissingRatingTargets(record.value)) ?? records[0] ?? null;
|
|
884
1031
|
}
|
|
885
1032
|
|
|
886
1033
|
private selectCachedArrayResource<T>(
|
|
@@ -1009,14 +1156,42 @@ export class AssetDataRouter implements DataProvider {
|
|
|
1009
1156
|
);
|
|
1010
1157
|
}
|
|
1011
1158
|
|
|
1012
|
-
private
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1159
|
+
private hasBrokerContext(context?: MarketDataRequestContext): boolean {
|
|
1160
|
+
return !!(
|
|
1161
|
+
context?.brokerId ||
|
|
1162
|
+
context?.brokerInstanceId ||
|
|
1163
|
+
context?.instrument?.brokerId ||
|
|
1164
|
+
context?.instrument?.brokerInstanceId
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
private hasCachedTargetBrokerContext(target: CachedFinancialsTarget): boolean {
|
|
1169
|
+
return !!(
|
|
1170
|
+
target.brokerId ||
|
|
1171
|
+
target.brokerInstanceId ||
|
|
1172
|
+
target.instrument?.brokerId ||
|
|
1173
|
+
target.instrument?.brokerInstanceId
|
|
1018
1174
|
);
|
|
1019
|
-
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
private contextFromCachedTarget(target: CachedFinancialsTarget): MarketDataRequestContext {
|
|
1178
|
+
return {
|
|
1179
|
+
brokerId: target.brokerId,
|
|
1180
|
+
brokerInstanceId: target.brokerInstanceId,
|
|
1181
|
+
instrument: target.instrument ?? null,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
private quoteBatchKey(target: QuoteSubscriptionTarget): string {
|
|
1186
|
+
return `${target.symbol.trim().toUpperCase()}:${canonicalExchange(target.exchange ?? "")}`;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
private cachedFinancialsBatchKey(target: CachedFinancialsTarget): string {
|
|
1190
|
+
return `${target.symbol.trim().toUpperCase()}:${canonicalExchange(target.exchange ?? "")}`;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
private getStreamingBrokerCandidate(target: QuoteSubscriptionTarget): BrokerCandidate | null {
|
|
1194
|
+
if (!this.hasBrokerContext(target.context)) return null;
|
|
1020
1195
|
return this.getBrokerCandidatesForContext(target.context, false)
|
|
1021
1196
|
.find((candidate) => typeof candidate.broker.subscribeQuotes === "function") ?? null;
|
|
1022
1197
|
}
|
|
@@ -1583,6 +1758,7 @@ export class AssetDataRouter implements DataProvider {
|
|
|
1583
1758
|
const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
|
|
1584
1759
|
const variantKey = this.getTickerVariantCandidates(exchange)[0] ?? "";
|
|
1585
1760
|
let firstEmptyResult: SourceResult<AnalystResearchData> | null = null;
|
|
1761
|
+
let firstIncompleteResult: SourceResult<AnalystResearchData> | null = null;
|
|
1586
1762
|
let lastError: unknown = null;
|
|
1587
1763
|
|
|
1588
1764
|
for (const provider of this.providersInPriorityOrder()) {
|
|
@@ -1591,7 +1767,13 @@ export class AssetDataRouter implements DataProvider {
|
|
|
1591
1767
|
const value = await provider.getAnalystResearch(ticker, exchange, context);
|
|
1592
1768
|
const sourceKey = this.providerSourceKey(provider);
|
|
1593
1769
|
this.cacheResource("analystResearch", entityKey, variantKey, sourceKey, value, this.resolveProviderPolicy("analystResearch", provider));
|
|
1594
|
-
if (hasAnalystResearchValue(value))
|
|
1770
|
+
if (hasAnalystResearchValue(value)) {
|
|
1771
|
+
if (isAnalystResearchMissingRatingTargets(value)) {
|
|
1772
|
+
firstIncompleteResult ??= { sourceKey, value };
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
return { sourceKey, value };
|
|
1776
|
+
}
|
|
1595
1777
|
firstEmptyResult ??= { sourceKey, value };
|
|
1596
1778
|
} catch (error) {
|
|
1597
1779
|
lastError = error;
|
|
@@ -1601,6 +1783,7 @@ export class AssetDataRouter implements DataProvider {
|
|
|
1601
1783
|
}
|
|
1602
1784
|
}
|
|
1603
1785
|
|
|
1786
|
+
if (firstIncompleteResult) return firstIncompleteResult;
|
|
1604
1787
|
if (firstEmptyResult) return firstEmptyResult;
|
|
1605
1788
|
if (lastError) throw lastError;
|
|
1606
1789
|
return null;
|
|
@@ -32,4 +32,48 @@ describe("YahooFinanceClient exchange aliases", () => {
|
|
|
32
32
|
});
|
|
33
33
|
expect(history[0]?.close).toBe(200);
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
test("preserves analyst rating price targets from upgrade history", async () => {
|
|
37
|
+
const provider = new YahooFinanceClient() as any;
|
|
38
|
+
let requestUrl = "";
|
|
39
|
+
provider.getSymbolsToTry = () => ["AMD"];
|
|
40
|
+
provider.fetchJsonWithCrumb = async (_label: string, url: string) => {
|
|
41
|
+
requestUrl = url;
|
|
42
|
+
return {
|
|
43
|
+
quoteSummary: {
|
|
44
|
+
result: [{
|
|
45
|
+
price: { symbol: "AMD", currency: "USD", exchangeName: "NasdaqGS" },
|
|
46
|
+
financialData: {},
|
|
47
|
+
recommendationTrend: { trend: [] },
|
|
48
|
+
earningsTrend: { trend: [] },
|
|
49
|
+
upgradeDowngradeHistory: {
|
|
50
|
+
history: [{
|
|
51
|
+
epochGradeDate: 1778188318,
|
|
52
|
+
firm: "Citigroup",
|
|
53
|
+
toGrade: "Neutral",
|
|
54
|
+
fromGrade: "Neutral",
|
|
55
|
+
action: "main",
|
|
56
|
+
priceTargetAction: "Raises",
|
|
57
|
+
currentPriceTarget: 358,
|
|
58
|
+
priorPriceTarget: 248,
|
|
59
|
+
}],
|
|
60
|
+
},
|
|
61
|
+
}],
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const research = await provider.getAnalystResearch("AMD", "NASDAQ");
|
|
67
|
+
|
|
68
|
+
expect(requestUrl).toContain("upgradeDowngradeHistory");
|
|
69
|
+
expect(research.ratings[0]).toMatchObject({
|
|
70
|
+
date: "2026-05-07",
|
|
71
|
+
firm: "Citigroup",
|
|
72
|
+
action: "Raises",
|
|
73
|
+
current: "Neutral",
|
|
74
|
+
prior: "Neutral",
|
|
75
|
+
currentPriceTarget: 358,
|
|
76
|
+
priorPriceTarget: 248,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
35
79
|
});
|
|
@@ -179,6 +179,8 @@ type QuoteSummaryResponse = {
|
|
|
179
179
|
priceTargetAction?: string;
|
|
180
180
|
toGrade?: string;
|
|
181
181
|
fromGrade?: string;
|
|
182
|
+
currentPriceTarget?: { raw?: number } | number | null;
|
|
183
|
+
priorPriceTarget?: { raw?: number } | number | null;
|
|
182
184
|
}>;
|
|
183
185
|
};
|
|
184
186
|
earningsTrend?: {
|
|
@@ -412,12 +414,16 @@ function mapYahooAnalystResearchResponse(
|
|
|
412
414
|
const action = normalizeYahooRatingAction(rating.action, rating.priceTargetAction);
|
|
413
415
|
const current = rating.toGrade?.trim();
|
|
414
416
|
const prior = rating.fromGrade?.trim();
|
|
417
|
+
const currentPriceTarget = financeRawNumber(rating.currentPriceTarget);
|
|
418
|
+
const priorPriceTarget = financeRawNumber(rating.priorPriceTarget);
|
|
415
419
|
return {
|
|
416
420
|
date,
|
|
417
421
|
firm,
|
|
418
422
|
...(action ? { action } : {}),
|
|
419
423
|
...(current ? { current } : {}),
|
|
420
424
|
...(prior ? { prior } : {}),
|
|
425
|
+
...(currentPriceTarget != null ? { currentPriceTarget } : {}),
|
|
426
|
+
...(priorPriceTarget != null ? { priorPriceTarget } : {}),
|
|
421
427
|
};
|
|
422
428
|
})
|
|
423
429
|
.filter((rating): rating is AnalystResearchData["ratings"][number] => rating !== null)
|
|
@@ -54,6 +54,8 @@ export interface InitializeAppStateArgs {
|
|
|
54
54
|
primeCachedFinancials?: (entries: Array<{ ticker: TickerRecord; financials: TickerFinancials }>) => void;
|
|
55
55
|
refreshTicker: (symbol: string, exchange?: string, tickerOverride?: TickerRecord | null, priority?: number) => void;
|
|
56
56
|
refreshQuote: (symbol: string, exchange?: string, tickerOverride?: TickerRecord | null, priority?: number) => void;
|
|
57
|
+
refreshTickersBatch?: (entries: Array<{ ticker: TickerRecord; priority: number }>) => void;
|
|
58
|
+
refreshQuotesBatch?: (entries: Array<{ ticker: TickerRecord; priority: number }>) => void;
|
|
57
59
|
autoImportBrokerPositions: (tickerMap: Map<string, TickerRecord>) => Promise<void>;
|
|
58
60
|
persistedBrokerAccounts?: Record<string, BrokerAccount[]>;
|
|
59
61
|
}
|
|
@@ -211,11 +213,11 @@ function buildCachedFinancialTarget(ticker: TickerRecord): CachedFinancialsTarge
|
|
|
211
213
|
};
|
|
212
214
|
}
|
|
213
215
|
|
|
214
|
-
function resolveCachedFinancialPrimeEntries(
|
|
216
|
+
async function resolveCachedFinancialPrimeEntries(
|
|
215
217
|
refreshPlan: RefreshPlanEntry[],
|
|
216
218
|
sessionSnapshot: AppSessionSnapshot | null | undefined,
|
|
217
219
|
dataProvider: DataProvider,
|
|
218
|
-
): Array<{ ticker: TickerRecord; financials: TickerFinancials }
|
|
220
|
+
): Promise<Array<{ ticker: TickerRecord; financials: TickerFinancials }>> {
|
|
219
221
|
if (!dataProvider.getCachedFinancialsForTargets) return [];
|
|
220
222
|
|
|
221
223
|
const sessionTargetsBySymbol = new Map<string, CachedFinancialsTarget>();
|
|
@@ -226,7 +228,7 @@ function resolveCachedFinancialPrimeEntries(
|
|
|
226
228
|
const financialEntries = refreshPlan.filter((entry) => entry.mode === "financials");
|
|
227
229
|
if (financialEntries.length === 0) return [];
|
|
228
230
|
|
|
229
|
-
const cachedFinancials = dataProvider.getCachedFinancialsForTargets(
|
|
231
|
+
const cachedFinancials = await dataProvider.getCachedFinancialsForTargets(
|
|
230
232
|
financialEntries.map(({ ticker }) => (
|
|
231
233
|
sessionTargetsBySymbol.get(ticker.metadata.ticker.trim().toUpperCase()) ?? buildCachedFinancialTarget(ticker)
|
|
232
234
|
)),
|
|
@@ -252,6 +254,8 @@ export async function initializeAppState({
|
|
|
252
254
|
primeCachedFinancials,
|
|
253
255
|
refreshTicker,
|
|
254
256
|
refreshQuote,
|
|
257
|
+
refreshTickersBatch,
|
|
258
|
+
refreshQuotesBatch,
|
|
255
259
|
autoImportBrokerPositions,
|
|
256
260
|
persistedBrokerAccounts = {},
|
|
257
261
|
}: InitializeAppStateArgs): Promise<void> {
|
|
@@ -341,7 +345,7 @@ export async function initializeAppState({
|
|
|
341
345
|
});
|
|
342
346
|
|
|
343
347
|
if (primeCachedFinancials) {
|
|
344
|
-
const cachedPrimeEntries =
|
|
348
|
+
const cachedPrimeEntries = await measurePerfAsync(
|
|
345
349
|
"startup.resolve-cached-financial-prime",
|
|
346
350
|
() => resolveCachedFinancialPrimeEntries(refreshPlan, sessionSnapshot, dataProvider),
|
|
347
351
|
{ financialRefreshCount: refreshPlan.filter((entry) => entry.mode === "financials").length },
|
|
@@ -362,10 +366,19 @@ export async function initializeAppState({
|
|
|
362
366
|
});
|
|
363
367
|
|
|
364
368
|
measurePerf("startup.enqueue-refresh-plan", () => {
|
|
365
|
-
|
|
366
|
-
|
|
369
|
+
const financialEntries = refreshPlan.filter((entry) => entry.mode === "financials");
|
|
370
|
+
const quoteEntries = refreshPlan.filter((entry) => entry.mode === "quote");
|
|
371
|
+
if (refreshTickersBatch) {
|
|
372
|
+
refreshTickersBatch(financialEntries.map((entry) => ({ ticker: entry.ticker, priority: entry.priority })));
|
|
373
|
+
} else {
|
|
374
|
+
for (const entry of financialEntries) {
|
|
367
375
|
refreshTicker(entry.ticker.metadata.ticker, entry.ticker.metadata.exchange, entry.ticker, entry.priority);
|
|
368
|
-
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (refreshQuotesBatch) {
|
|
379
|
+
refreshQuotesBatch(quoteEntries.map((entry) => ({ ticker: entry.ticker, priority: entry.priority })));
|
|
380
|
+
} else {
|
|
381
|
+
for (const entry of quoteEntries) {
|
|
369
382
|
refreshQuote(entry.ticker.metadata.ticker, entry.ticker.metadata.exchange, entry.ticker, entry.priority);
|
|
370
383
|
}
|
|
371
384
|
}
|