gloomberb 0.7.3 → 0.7.4

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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/components/chart/chart-renderer.ts +109 -32
  3. package/src/components/chart/chart-types.ts +18 -0
  4. package/src/components/chart/chart.test.ts +152 -5
  5. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  6. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  7. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  8. package/src/components/chart/market-session.ts +187 -0
  9. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  10. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  11. package/src/components/chart/stock-chart.tsx +36 -8
  12. package/src/components/chart/time-axis-label.tsx +56 -2
  13. package/src/components/command-bar/command-bar.test.tsx +1 -1
  14. package/src/components/command-bar/command-bar.tsx +7 -1
  15. package/src/components/command-bar/list-model.ts +8 -4
  16. package/src/components/command-bar/view-model.test.ts +20 -0
  17. package/src/components/command-bar/view-model.ts +15 -3
  18. package/src/components/ticker-list-table.tsx +4 -15
  19. package/src/core/state/session-persistence.ts +25 -0
  20. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  21. package/src/market-data/coordinator.test.ts +122 -2
  22. package/src/market-data/coordinator.ts +192 -55
  23. package/src/market-data/hooks.test.tsx +31 -0
  24. package/src/market-data/hooks.tsx +1 -0
  25. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  26. package/src/plugins/builtin/chat-controller.ts +8 -7
  27. package/src/plugins/builtin/chat.test.tsx +55 -0
  28. package/src/plugins/builtin/chat.tsx +20 -6
  29. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  30. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  31. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  32. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  33. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  34. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  35. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  36. package/src/plugins/builtin/sectors/index.tsx +30 -9
  37. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  38. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  39. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  40. package/src/sources/gloomberb-cloud.test.ts +55 -2
  41. package/src/sources/gloomberb-cloud.ts +180 -29
  42. package/src/sources/provider-router.test.ts +567 -10
  43. package/src/sources/provider-router.ts +323 -93
  44. package/src/sources/yahoo-finance.test.ts +37 -0
  45. package/src/sources/yahoo-finance.ts +48 -2
  46. package/src/state/app-bootstrap.test.ts +50 -23
  47. package/src/state/app-bootstrap.ts +26 -10
  48. package/src/state/session-persistence.test.ts +5 -3
  49. package/src/state/use-inline-tickers.ts +3 -0
  50. package/src/state/use-quote-streaming.ts +11 -0
  51. package/src/types/data-provider.ts +5 -1
  52. package/src/types/financials.ts +1 -0
  53. package/src/utils/api-client.test.ts +120 -0
  54. package/src/utils/api-client.ts +66 -19
  55. package/src/utils/exchanges.test.ts +16 -0
  56. package/src/utils/exchanges.ts +33 -0
  57. package/src/utils/options.ts +1 -1
  58. package/src/utils/price-history.test.ts +28 -1
  59. package/src/utils/price-history.ts +16 -0
  60. package/src/utils/quote-freshness.test.ts +150 -0
  61. package/src/utils/quote-freshness.ts +42 -1
  62. package/src/utils/quote-resolution.ts +2 -0
  63. package/src/utils/quote-time.test.ts +34 -0
  64. package/src/utils/quote-time.ts +18 -5
  65. package/src/version.ts +1 -1
  66. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -3,6 +3,7 @@ import { existsSync, rmSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { act, useReducer } from "react";
6
+ import { RGBA, TextAttributes } from "@opentui/core";
6
7
  import { testRender } from "../../renderers/opentui/test-utils";
7
8
  import { AppPersistence } from "../../data/app-persistence";
8
9
  import { TickerRepository } from "../../data/ticker-repository";
@@ -18,6 +19,7 @@ import { createTestPluginRuntime } from "../../test-support/plugin-runtime";
18
19
  import { PluginRenderProvider, type PluginRuntimeAccess } from "../plugin-runtime";
19
20
  import { PluginRegistry, setSharedMarketDataForTests, setSharedRegistryForTests } from "../registry";
20
21
  import type { BrokerAdapter } from "../../types/broker";
22
+ import { colors } from "../../theme/colors";
21
23
  import { portfolioListPlugin } from "./portfolio-list";
22
24
 
23
25
  const TEST_PANE_ID = "portfolio-list:test";
@@ -316,6 +318,10 @@ async function flushFrame() {
316
318
  });
317
319
  }
318
320
 
321
+ function lineText(line: { spans: Array<{ text: string }> }) {
322
+ return line.spans.map((span) => span.text).join("");
323
+ }
324
+
319
325
  afterEach(async () => {
320
326
  if (testSetup) {
321
327
  testSetup.renderer.destroy();
@@ -1122,10 +1128,11 @@ describe("PortfolioListPane cash and margin UI", () => {
1122
1128
  quote: makeQuote({
1123
1129
  symbol: "AAPL",
1124
1130
  price: 125,
1125
- marketCap: undefined,
1131
+ marketCap: 2_000_000_000,
1126
1132
  }),
1127
1133
  fundamentals: {
1128
1134
  trailingPE: 25,
1135
+ forwardPE: 22,
1129
1136
  },
1130
1137
  profile: {
1131
1138
  sector: "Technology",
@@ -1437,7 +1444,7 @@ describe("PortfolioListPane cash and margin UI", () => {
1437
1444
  expect(frame).toContain("+5.41%");
1438
1445
  });
1439
1446
 
1440
- test("quote flash keeps the existing row background intact", async () => {
1447
+ test("quote flash dims cells without changing quote sign color or row background", async () => {
1441
1448
  const config = createPortfolioConfigWithColumns(
1442
1449
  "broker:ibkr-flex:DU12345",
1443
1450
  ["ticker", "price", "change", "latency"],
@@ -1454,7 +1461,18 @@ describe("PortfolioListPane cash and margin UI", () => {
1454
1461
  ["MSFT", makeTicker({ ticker: "MSFT", name: "Microsoft" })],
1455
1462
  ]);
1456
1463
  state.financials = new Map([
1457
- ["AAPL", { annualStatements: [], quarterlyStatements: [], priceHistory: [], quote: makeQuote() }],
1464
+ ["AAPL", {
1465
+ annualStatements: [],
1466
+ quarterlyStatements: [],
1467
+ priceHistory: [],
1468
+ quote: makeQuote({
1469
+ price: 125,
1470
+ bid: 124.95,
1471
+ ask: 125.05,
1472
+ change: -5,
1473
+ changePercent: -4.17,
1474
+ }),
1475
+ }],
1458
1476
  ["MSFT", {
1459
1477
  annualStatements: [],
1460
1478
  quarterlyStatements: [],
@@ -1483,7 +1501,7 @@ describe("PortfolioListPane cash and margin UI", () => {
1483
1501
 
1484
1502
  await flushFrame();
1485
1503
  const beforeFrame = testSetup.captureSpans();
1486
- const beforeLine = beforeFrame.lines.find((line) => line.spans.map((span) => span.text).join("").includes("AAPL"));
1504
+ const beforeLine = beforeFrame.lines.find((line) => lineText(line).includes("AAPL"));
1487
1505
  expect(beforeLine).toBeDefined();
1488
1506
 
1489
1507
  await act(async () => {
@@ -1494,8 +1512,8 @@ describe("PortfolioListPane cash and margin UI", () => {
1494
1512
  price: 126,
1495
1513
  bid: 125.95,
1496
1514
  ask: 126.05,
1497
- change: 6,
1498
- changePercent: 5,
1515
+ change: -4,
1516
+ changePercent: -3.08,
1499
1517
  lastUpdated: Date.now() + 1_000,
1500
1518
  }),
1501
1519
  });
@@ -1503,11 +1521,15 @@ describe("PortfolioListPane cash and margin UI", () => {
1503
1521
  await flushFrame();
1504
1522
 
1505
1523
  const frame = testSetup.captureSpans();
1506
- const aaplLine = frame.lines.find((line) => line.spans.map((span) => span.text).join("").includes("AAPL"));
1524
+ const aaplLine = frame.lines.find((line) => lineText(line).includes("AAPL"));
1507
1525
  expect(aaplLine).toBeDefined();
1508
1526
 
1509
1527
  const beforeBackgrounds = beforeLine!.spans.map((span) => span.bg.toInts().join(","));
1510
1528
  const afterBackgrounds = aaplLine!.spans.map((span) => span.bg.toInts().join(","));
1511
1529
  expect(afterBackgrounds).toEqual(beforeBackgrounds);
1530
+ const priceSpan = aaplLine!.spans.find((span) => span.text.includes("126"));
1531
+ expect(priceSpan).toBeDefined();
1532
+ expect(priceSpan!.fg.toInts().join(",")).toBe(RGBA.fromHex(colors.negative).toInts().join(","));
1533
+ expect(priceSpan!.attributes & TextAttributes.DIM).not.toBe(0);
1512
1534
  });
1513
1535
  });
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import { Box } from "../../../ui";
3
3
  import { DataTableView, Tabs, usePaneFooter, type DataTableCell, type DataTableColumn, type DataTableKeyEvent } from "../../../components";
4
4
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
5
- import type { PricePoint } from "../../../types/financials";
5
+ import type { PricePoint, Quote } from "../../../types/financials";
6
6
  import { colors, priceColor } from "../../../theme/colors";
7
7
  import { formatCurrency, formatPercentRaw } from "../../../utils/format";
8
8
  import { useAssetData, useDebouncedPluginPaneState, usePluginPaneState, usePluginTickerActions } from "../../plugin-runtime";
@@ -300,14 +300,35 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
300
300
  rows.map((row) => ({ ...row, loading: true }))
301
301
  )));
302
302
 
303
- const fetches = sectorDefs.map(async (sector) => {
303
+ const loadQuotes = async (): Promise<Map<string, Quote | null>> => {
304
+ const quotes = new Map<string, Quote | null>();
305
+ if (dataProvider.getQuotesBatch) {
306
+ const results = await dataProvider.getQuotesBatch(
307
+ sectorDefs.map((sector) => ({ symbol: sector.etf, exchange: "" })),
308
+ ).catch(() => []);
309
+ for (const result of results) {
310
+ quotes.set(result.target.symbol, result.quote ?? null);
311
+ }
312
+ return quotes;
313
+ }
314
+
315
+ await Promise.all(sectorDefs.map(async (sector) => {
316
+ try {
317
+ quotes.set(sector.etf, await dataProvider.getQuote(sector.etf, ""));
318
+ } catch {
319
+ quotes.set(sector.etf, null);
320
+ }
321
+ }));
322
+ return quotes;
323
+ };
324
+
325
+ const fetches = loadQuotes().then((quotesByEtf) => Promise.allSettled(sectorDefs.map(async (sector) => {
304
326
  try {
305
- const [quoteResult, historyResult] = await Promise.allSettled([
306
- dataProvider.getQuote(sector.etf, ""),
307
- dataProvider.getPriceHistory(sector.etf, "", "1Y"),
308
- ]);
327
+ const historyResult = await dataProvider.getPriceHistory(sector.etf, "", "1Y")
328
+ .then((value) => ({ status: "fulfilled" as const, value }))
329
+ .catch(() => ({ status: "rejected" as const }));
309
330
  if (fetchGenRef.current !== gen) return;
310
- const quote = quoteResult.status === "fulfilled" ? quoteResult.value : null;
331
+ const quote = quotesByEtf.get(sector.etf) ?? null;
311
332
  const history = historyResult.status === "fulfilled" ? historyResult.value : [];
312
333
  const historyClose = latestHistoryClose(history);
313
334
  const price = quote?.price ?? historyClose;
@@ -336,9 +357,9 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
336
357
  )
337
358
  )));
338
359
  }
339
- });
360
+ })));
340
361
 
341
- Promise.allSettled(fetches).then(() => {
362
+ fetches.then(() => {
342
363
  if (fetchGenRef.current === gen) {
343
364
  setLastRefreshByCollection((prev) => ({
344
365
  ...prev,
@@ -62,7 +62,24 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
62
62
  const paneInstance = usePaneInstance();
63
63
  const { symbol, ticker, financials } = usePaneTicker();
64
64
  const streamingTarget = quoteSubscriptionTargetFromTicker(ticker, ticker?.metadata.ticker, "provider");
65
- const streamingTargets = useMemo(() => (streamingTarget ? [streamingTarget] : []), [streamingTarget]);
65
+ const streamingTargets = useMemo(() => (
66
+ streamingTarget
67
+ ? [{
68
+ ...streamingTarget,
69
+ surface: "detail" as const,
70
+ visible: true,
71
+ selected: true,
72
+ weight: 100,
73
+ }]
74
+ : []
75
+ ), [
76
+ streamingTarget?.symbol,
77
+ streamingTarget?.exchange,
78
+ streamingTarget?.route,
79
+ streamingTarget?.context?.brokerId,
80
+ streamingTarget?.context?.brokerInstanceId,
81
+ streamingTarget?.context?.instrument,
82
+ ]);
66
83
  useQuoteStreaming(streamingTargets);
67
84
 
68
85
  const { collectionId } = usePaneCollection();
@@ -371,7 +371,10 @@ export function QuoteMonitorPane({ paneId, focused, width, height }: PaneProps)
371
371
  symbols
372
372
  .map((symbol) => {
373
373
  const ticker = tickersBySymbol.get(symbol) ?? (fallbackTicker?.metadata.ticker === symbol ? fallbackTicker : null);
374
- return quoteSubscriptionTargetFromTicker(ticker, symbol, "provider");
374
+ const target = quoteSubscriptionTargetFromTicker(ticker, symbol, "provider");
375
+ return target
376
+ ? { ...target, surface: "monitor" as const, visible: true, selected: symbol === fallbackSymbol, weight: symbol === fallbackSymbol ? 100 : 90 }
377
+ : null;
375
378
  })
376
379
  .filter((target): target is QuoteSubscriptionTarget => target != null)
377
380
  ), [fallbackTicker, symbols, tickersBySymbol]);
@@ -183,31 +183,70 @@ function WorldIndicesPane({ focused, width, height }: PaneProps) {
183
183
  fetchGenRef.current += 1;
184
184
  const gen = fetchGenRef.current;
185
185
 
186
- for (const entry of WORLD_INDICES) {
187
- setQuotes((prev) => {
188
- const next = new Map(prev);
186
+ setQuotes((prev) => {
187
+ const next = new Map(prev);
188
+ for (const entry of WORLD_INDICES) {
189
189
  const existing = next.get(entry.symbol);
190
190
  next.set(entry.symbol, { quote: existing?.quote ?? null, loading: true, error: null });
191
+ }
192
+ return next;
193
+ });
194
+
195
+ const loadQuotes = async (): Promise<QuoteMap> => {
196
+ const next = new Map<string, IndexQuoteState>();
197
+ if (dataProvider.getQuotesBatch) {
198
+ const results = await dataProvider.getQuotesBatch(
199
+ WORLD_INDICES.map((entry) => ({ symbol: entry.symbol, exchange: "" })),
200
+ );
201
+ const bySymbol = new Map(results.map((result) => [result.target.symbol, result]));
202
+ for (const entry of WORLD_INDICES) {
203
+ const result = bySymbol.get(entry.symbol);
204
+ next.set(entry.symbol, {
205
+ quote: result?.quote ?? null,
206
+ loading: false,
207
+ error: result?.error
208
+ ? result.error instanceof Error ? result.error.message : String(result.error)
209
+ : null,
210
+ });
211
+ }
191
212
  return next;
192
- });
213
+ }
193
214
 
194
- dataProvider.getQuote(entry.symbol, "").then((quote) => {
195
- if (fetchGenRef.current !== gen) return;
196
- setQuotes((prev) => {
197
- const next = new Map(prev);
215
+ await Promise.all(WORLD_INDICES.map(async (entry) => {
216
+ try {
217
+ const quote = await dataProvider.getQuote(entry.symbol, "");
198
218
  next.set(entry.symbol, { quote, loading: false, error: null });
199
- return next;
200
- });
201
- }).catch((err: unknown) => {
202
- if (fetchGenRef.current !== gen) return;
203
- const msg = err instanceof Error ? err.message : String(err);
204
- setQuotes((prev) => {
205
- const next = new Map(prev);
206
- next.set(entry.symbol, { quote: null, loading: false, error: msg });
207
- return next;
208
- });
219
+ } catch (err: unknown) {
220
+ next.set(entry.symbol, {
221
+ quote: null,
222
+ loading: false,
223
+ error: err instanceof Error ? err.message : String(err),
224
+ });
225
+ }
226
+ }));
227
+ return next;
228
+ };
229
+
230
+ loadQuotes().then((nextQuotes) => {
231
+ if (fetchGenRef.current !== gen) return;
232
+ setQuotes((prev) => {
233
+ const next = new Map(prev);
234
+ for (const [symbol, state] of nextQuotes) {
235
+ next.set(symbol, state);
236
+ }
237
+ return next;
209
238
  });
210
- }
239
+ }).catch((err: unknown) => {
240
+ if (fetchGenRef.current !== gen) return;
241
+ const message = err instanceof Error ? err.message : String(err);
242
+ setQuotes((prev) => {
243
+ const next = new Map(prev);
244
+ for (const entry of WORLD_INDICES) {
245
+ next.set(entry.symbol, { quote: null, loading: false, error: message });
246
+ }
247
+ return next;
248
+ });
249
+ });
211
250
  }, [dataProvider]);
212
251
 
213
252
  useEffect(() => {
@@ -20,6 +20,7 @@ const originalGetCloudQuote = apiClient.getCloudQuote.bind(apiClient);
20
20
  const originalGetCloudHolders = apiClient.getCloudHolders.bind(apiClient);
21
21
  const originalGetCloudAnalystResearch = apiClient.getCloudAnalystResearch.bind(apiClient);
22
22
  const originalGetCloudCorporateActions = apiClient.getCloudCorporateActions.bind(apiClient);
23
+ const originalGetCloudOptionsChain = apiClient.getCloudOptionsChain.bind(apiClient);
23
24
  const originalGetCloudNews = apiClient.getCloudNews.bind(apiClient);
24
25
  const originalGetCloudNewsStory = apiClient.getCloudNewsStory.bind(apiClient);
25
26
  const originalSubscribeQuotes = apiClient.subscribeQuotes.bind(apiClient);
@@ -78,6 +79,7 @@ afterEach(() => {
78
79
  apiClient.getCloudHolders = originalGetCloudHolders;
79
80
  apiClient.getCloudAnalystResearch = originalGetCloudAnalystResearch;
80
81
  apiClient.getCloudCorporateActions = originalGetCloudCorporateActions;
82
+ apiClient.getCloudOptionsChain = originalGetCloudOptionsChain;
81
83
  apiClient.getCloudNews = originalGetCloudNews;
82
84
  apiClient.getCloudNewsStory = originalGetCloudNewsStory;
83
85
  apiClient.subscribeQuotes = originalSubscribeQuotes;
@@ -103,8 +105,8 @@ describe("GloomberbCloudProvider", () => {
103
105
  const history = await provider.getDetailedPriceHistory(
104
106
  "AAPL",
105
107
  "NASDAQ",
106
- new Date(2026, 2, 27, 10, 0, 0),
107
- new Date(2026, 2, 27, 12, 0, 0),
108
+ new Date("2026-03-27T14:00:00Z"),
109
+ new Date("2026-03-27T16:00:00Z"),
108
110
  "15m",
109
111
  );
110
112
 
@@ -119,6 +121,7 @@ describe("GloomberbCloudProvider", () => {
119
121
  });
120
122
  expect(history).toHaveLength(1);
121
123
  expect(history[0]?.close).toBe(250.12);
124
+ expect(history[0]?.date.toISOString()).toBe("2026-03-27T14:15:00.000Z");
122
125
  });
123
126
 
124
127
  test("normalizes daily detailed history requests to 1day", async () => {
@@ -173,6 +176,11 @@ describe("GloomberbCloudProvider", () => {
173
176
  expect(history[0]?.close).toBe(250.12);
174
177
  });
175
178
 
179
+ test("does not advertise unsupported 45 minute chart resolution", () => {
180
+ const provider = new GloomberbCloudProvider();
181
+ expect(provider.getChartResolutionCapabilities()).not.toContain("45m");
182
+ });
183
+
176
184
  test("normalizes sub-unit cloud quotes to their main currency", async () => {
177
185
  apiClient.ensureVerifiedSession = async () => verifiedUser;
178
186
  apiClient.getCloudQuote = async () => ({
@@ -319,6 +327,51 @@ describe("GloomberbCloudProvider", () => {
319
327
  expect(actions.earnings[0]?.surprisePercent).toBe(6.37);
320
328
  });
321
329
 
330
+ test("fetches options chains from the cloud market endpoint", async () => {
331
+ apiClient.ensureVerifiedSession = async () => verifiedUser;
332
+
333
+ const requestArgs: { current: { symbol: string; exchange?: string; expirationDate?: number } | null } = { current: null };
334
+ apiClient.getCloudOptionsChain = async (symbol, exchange, expirationDate) => {
335
+ requestArgs.current = { symbol, exchange, expirationDate };
336
+ return {
337
+ status: "success",
338
+ data: {
339
+ providerId: "gloomberb-cloud",
340
+ underlyingSymbol: "AMD",
341
+ expirationDates: [1_800_000_000],
342
+ calls: [{
343
+ contractSymbol: "AMD270917C00230000",
344
+ strike: 230,
345
+ currency: "USD",
346
+ lastPrice: 11,
347
+ change: 1,
348
+ percentChange: 10,
349
+ volume: 10,
350
+ openInterest: 20,
351
+ bid: 10,
352
+ ask: 12,
353
+ impliedVolatility: 0.4,
354
+ inTheMoney: false,
355
+ expiration: 1_800_000_000,
356
+ lastTradeDate: 1_799_000_000,
357
+ }],
358
+ puts: [],
359
+ },
360
+ };
361
+ };
362
+
363
+ const provider = new GloomberbCloudProvider();
364
+ const chain = await provider.getOptionsChain("AMD", "NASDAQ", 1_800_000_000);
365
+
366
+ expect(requestArgs.current).toEqual({
367
+ symbol: "AMD",
368
+ exchange: "NASDAQ",
369
+ expirationDate: 1_800_000_000,
370
+ });
371
+ expect(chain.underlyingSymbol).toBe("AMD");
372
+ expect(chain.calls[0]?.contractSymbol).toBe("AMD270917C00230000");
373
+ });
374
+
322
375
  test("preserves original target context when streaming quotes", () => {
323
376
  let unsubscribeCalled = false;
324
377
  const seenQuotes: Array<{ price: number; currency: string }> = [];