gloomberb 0.7.2 → 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 (167) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +158 -47
  7. package/src/components/chart/chart-types.ts +20 -2
  8. package/src/components/chart/chart.test.ts +179 -13
  9. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  10. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  11. package/src/components/chart/comparison-stock-chart.tsx +129 -129
  12. package/src/components/chart/market-session.ts +187 -0
  13. package/src/components/chart/native/chart-rasterizer.ts +108 -19
  14. package/src/components/chart/price-axis-labels.tsx +25 -9
  15. package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
  16. package/src/components/chart/stock-chart.tsx +95 -49
  17. package/src/components/chart/time-axis-label.tsx +56 -2
  18. package/src/components/command-bar/command-bar.test.tsx +446 -120
  19. package/src/components/command-bar/command-bar.tsx +535 -384
  20. package/src/components/command-bar/command-registry.ts +16 -6
  21. package/src/components/command-bar/list-model.ts +8 -4
  22. package/src/components/command-bar/root-shortcuts.ts +4 -5
  23. package/src/components/command-bar/view-model.test.ts +21 -0
  24. package/src/components/command-bar/view-model.ts +17 -4
  25. package/src/components/command-bar/workflow-ops.ts +11 -32
  26. package/src/components/data-table-view.test.tsx +24 -1
  27. package/src/components/data-table-view.tsx +3 -2
  28. package/src/components/feed-data-table-stack-view.tsx +3 -2
  29. package/src/components/layout/detached-pane-shell.tsx +2 -2
  30. package/src/components/layout/floating-pane.tsx +9 -2
  31. package/src/components/layout/pane-footer.tsx +1 -1
  32. package/src/components/layout/pane-header.tsx +4 -2
  33. package/src/components/layout/pane-sizing.ts +12 -0
  34. package/src/components/layout/pane.tsx +24 -2
  35. package/src/components/layout/shell.tsx +24 -10
  36. package/src/components/layout/status-bar.test.tsx +4 -1
  37. package/src/components/layout/status-bar.tsx +4 -24
  38. package/src/components/markdown-text.tsx +53 -8
  39. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  40. package/src/components/pane-settings-dialog.tsx +9 -6
  41. package/src/components/pane-template-wizard.tsx +3 -2
  42. package/src/components/price-selector-dialog.tsx +3 -2
  43. package/src/components/price-sparkline-view.tsx +463 -0
  44. package/src/components/price-sparkline.ts +46 -0
  45. package/src/components/table-view-shared.tsx +21 -3
  46. package/src/components/ticker-list-table-view.tsx +3 -2
  47. package/src/components/ticker-list-table.tsx +15 -66
  48. package/src/components/toggle-list.tsx +3 -3
  49. package/src/components/ui/choice-dialog.tsx +3 -2
  50. package/src/components/ui/multi-select-dialog.tsx +3 -2
  51. package/src/components/ui/native-select.tsx +3 -3
  52. package/src/core/state/app-state.ts +210 -80
  53. package/src/core/state/session-persistence.ts +25 -0
  54. package/src/data/config-store-node.ts +113 -25
  55. package/src/data/config-store.test.ts +65 -3
  56. package/src/data/plugin-state-store.ts +39 -13
  57. package/src/data/sqlite-cache.test.ts +13 -0
  58. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  59. package/src/market-data/coordinator.test.ts +122 -2
  60. package/src/market-data/coordinator.ts +200 -56
  61. package/src/market-data/hooks.test.tsx +31 -0
  62. package/src/market-data/hooks.tsx +1 -0
  63. package/src/news/aggregator.test.ts +41 -0
  64. package/src/news/aggregator.ts +61 -9
  65. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  66. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  67. package/src/plugins/builtin/ai/settings.ts +3 -0
  68. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  69. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  70. package/src/plugins/builtin/chat-controller.ts +263 -48
  71. package/src/plugins/builtin/chat.test.tsx +312 -6
  72. package/src/plugins/builtin/chat.tsx +245 -66
  73. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  74. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  75. package/src/plugins/builtin/correlation/colors.ts +87 -0
  76. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  77. package/src/plugins/builtin/correlation/index.tsx +92 -21
  78. package/src/plugins/builtin/debug.tsx +4 -3
  79. package/src/plugins/builtin/econ/index.tsx +3 -2
  80. package/src/plugins/builtin/help.test.tsx +2 -2
  81. package/src/plugins/builtin/holders/index.tsx +25 -13
  82. package/src/plugins/builtin/layout-manager.tsx +1 -7
  83. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  84. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  85. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  86. package/src/plugins/builtin/news-wire/index.tsx +1 -3
  87. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  88. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  89. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  90. package/src/plugins/builtin/options.tsx +9 -17
  91. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  92. package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
  93. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  94. package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
  95. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  96. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  97. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  98. package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
  99. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  100. package/src/plugins/builtin/sectors/index.tsx +275 -43
  101. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  102. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  103. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  104. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  105. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
  106. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
  107. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  108. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  109. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  110. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  111. package/src/plugins/plugin-runtime.tsx +2 -2
  112. package/src/plugins/prediction-markets/settings.ts +4 -0
  113. package/src/plugins/registry.test.ts +55 -0
  114. package/src/plugins/registry.ts +11 -4
  115. package/src/plugins/ticker-navigation.test.ts +35 -5
  116. package/src/plugins/ticker-navigation.ts +16 -22
  117. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  118. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  119. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  120. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  121. package/src/renderers/electrobun/bun/index.ts +52 -13
  122. package/src/renderers/electrobun/view/app-services.ts +51 -9
  123. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  124. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  125. package/src/renderers/electrobun/view/main.tsx +0 -16
  126. package/src/renderers/electrobun/view/styles.css +23 -21
  127. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  128. package/src/renderers/opentui/host.tsx +2 -1
  129. package/src/renderers/opentui/start.tsx +1 -1
  130. package/src/renderers/opentui/test-utils.tsx +6 -5
  131. package/src/sources/gloomberb-cloud.test.ts +55 -2
  132. package/src/sources/gloomberb-cloud.ts +180 -29
  133. package/src/sources/provider-router.test.ts +567 -10
  134. package/src/sources/provider-router.ts +323 -93
  135. package/src/sources/yahoo-finance.test.ts +37 -0
  136. package/src/sources/yahoo-finance.ts +48 -2
  137. package/src/state/app-bootstrap.test.ts +50 -23
  138. package/src/state/app-bootstrap.ts +29 -13
  139. package/src/state/app-context.test.ts +113 -0
  140. package/src/state/app-context.tsx +8 -4
  141. package/src/state/session-persistence.test.ts +5 -3
  142. package/src/state/use-inline-tickers.ts +3 -0
  143. package/src/state/use-quote-streaming.ts +11 -0
  144. package/src/theme/colors.ts +13 -14
  145. package/src/types/config.ts +9 -4
  146. package/src/types/data-provider.ts +5 -1
  147. package/src/types/financials.ts +1 -0
  148. package/src/types/plugin.ts +8 -1
  149. package/src/ui/context-menu.tsx +4 -4
  150. package/src/utils/api-client.test.ts +248 -0
  151. package/src/utils/api-client.ts +178 -26
  152. package/src/utils/exchanges.test.ts +16 -0
  153. package/src/utils/exchanges.ts +33 -0
  154. package/src/utils/fuzzy-search.ts +12 -1
  155. package/src/utils/keyboard.ts +31 -0
  156. package/src/utils/options.ts +1 -1
  157. package/src/utils/price-history.test.ts +28 -1
  158. package/src/utils/price-history.ts +16 -0
  159. package/src/utils/quote-freshness.test.ts +150 -0
  160. package/src/utils/quote-freshness.ts +42 -1
  161. package/src/utils/quote-resolution.ts +2 -0
  162. package/src/utils/quote-time.test.ts +34 -0
  163. package/src/utils/quote-time.ts +18 -5
  164. package/src/utils/ticker-search.test.ts +2 -2
  165. package/src/utils/ticker-search.ts +48 -1
  166. package/src/version.ts +1 -1
  167. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -33,6 +33,43 @@ describe("YahooFinanceClient exchange aliases", () => {
33
33
  expect(history[0]?.close).toBe(200);
34
34
  });
35
35
 
36
+ test("maps IBKR option symbols to Yahoo option quote marks", async () => {
37
+ const provider = new YahooFinanceClient() as any;
38
+ provider.getOptionsChain = async (ticker: string, _exchange: string, expirationDate: number) => ({
39
+ underlyingSymbol: ticker,
40
+ expirationDates: [expirationDate],
41
+ calls: [{
42
+ contractSymbol: "AMD270917C00230000",
43
+ strike: 230,
44
+ currency: "USD",
45
+ lastPrice: 251,
46
+ change: 1.5,
47
+ percentChange: 0.6,
48
+ volume: 10,
49
+ openInterest: 20,
50
+ bid: 250.25,
51
+ ask: 254.5,
52
+ impliedVolatility: 0.4,
53
+ inTheMoney: false,
54
+ expiration: expirationDate,
55
+ lastTradeDate: 1_800_000_000,
56
+ }],
57
+ puts: [],
58
+ });
59
+
60
+ const quote = await provider.getQuote("AMD 270917C00230000");
61
+
62
+ expect(quote).toMatchObject({
63
+ symbol: "AMD 270917C00230000",
64
+ price: 252.375,
65
+ mark: 252.375,
66
+ bid: 250.25,
67
+ ask: 254.5,
68
+ providerId: "yahoo",
69
+ });
70
+ expect(quote.lastUpdated).toBe(1_800_000_000_000);
71
+ });
72
+
36
73
  test("preserves analyst rating price targets from upgrade history", async () => {
37
74
  const provider = new YahooFinanceClient() as any;
38
75
  let requestUrl = "";
@@ -7,6 +7,7 @@ import {
7
7
  type ManualChartResolution,
8
8
  } from "../components/chart/chart-resolution";
9
9
  import type { InstrumentSearchResult } from "../types/instrument";
10
+ import { parseOptionSymbol } from "../utils/options";
10
11
  import { SecEdgarClient } from "./sec-edgar";
11
12
 
12
13
  // Exchange suffix mapping for Yahoo Finance ticker symbols
@@ -755,7 +756,7 @@ export class YahooFinanceClient implements DataProvider {
755
756
  const history: PricePoint[] = [];
756
757
  for (let i = 0; i < result.timestamp.length; i++) {
757
758
  const close = quote.close?.[i];
758
- if (close == null || Number.isNaN(close)) continue;
759
+ if (close == null || !Number.isFinite(close) || close <= 0) continue;
759
760
  history.push({
760
761
  date: new Date((result.timestamp[i]!) * 1000),
761
762
  open: quote.open?.[i] ?? undefined,
@@ -1047,8 +1048,53 @@ export class YahooFinanceClient implements DataProvider {
1047
1048
  };
1048
1049
  }
1049
1050
 
1051
+ private async getOptionQuote(ticker: string, context?: MarketDataRequestContext): Promise<Quote> {
1052
+ const parsed = parseOptionSymbol(ticker);
1053
+ if (!parsed) throw new Error(`Unsupported option symbol ${ticker}`);
1054
+ const chain = await this.getOptionsChain(parsed.underlying, "", parsed.expTs, context);
1055
+ const contracts = parsed.side === "C" ? chain.calls : chain.puts;
1056
+ const contract = contracts.find((candidate) =>
1057
+ Math.abs(candidate.strike - parsed.strike) < 0.001 &&
1058
+ candidate.expiration === parsed.expTs
1059
+ );
1060
+ if (!contract) throw new Error(`No option contract for ${ticker}`);
1061
+
1062
+ const mark = contract.bid > 0 && contract.ask > 0
1063
+ ? (contract.bid + contract.ask) / 2
1064
+ : contract.bid > 0
1065
+ ? contract.bid
1066
+ : contract.ask > 0
1067
+ ? contract.ask
1068
+ : undefined;
1069
+ return {
1070
+ symbol: ticker,
1071
+ providerId: this.id,
1072
+ price: mark ?? contract.lastPrice,
1073
+ currency: contract.currency || "USD",
1074
+ change: contract.change,
1075
+ changePercent: contract.percentChange,
1076
+ volume: contract.volume,
1077
+ bid: contract.bid,
1078
+ ask: contract.ask,
1079
+ mark,
1080
+ name: contract.contractSymbol,
1081
+ lastUpdated: contract.lastTradeDate > 0
1082
+ ? contract.lastTradeDate * 1000
1083
+ : Date.now(),
1084
+ exchangeName: "OPTIONS",
1085
+ fullExchangeName: "OPTIONS",
1086
+ listingExchangeName: "OPTIONS",
1087
+ listingExchangeFullName: "OPTIONS",
1088
+ dataSource: "delayed",
1089
+ };
1090
+ }
1091
+
1050
1092
  /** Fetch just a quote (lighter weight) */
1051
- async getQuote(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise<Quote> {
1093
+ async getQuote(ticker: string, exchange = "", context?: MarketDataRequestContext): Promise<Quote> {
1094
+ if (parseOptionSymbol(ticker)) {
1095
+ return this.getOptionQuote(ticker, context);
1096
+ }
1097
+
1052
1098
  const symbolsToTry = this.getSymbolsToTry(ticker, exchange);
1053
1099
  let lastError: any;
1054
1100
 
@@ -408,8 +408,28 @@ describe("initializeAppState", () => {
408
408
  custom: {},
409
409
  tags: [],
410
410
  });
411
+ const hydrationSymbols = [
412
+ "AAPL",
413
+ ...Array.from({ length: 13 }, (_, index) => `T${String(index + 1).padStart(2, "0")}`),
414
+ ];
415
+ for (const symbol of hydrationSymbols.slice(1)) {
416
+ await tickerRepository.createTicker({
417
+ ticker: symbol,
418
+ exchange: "NASDAQ",
419
+ currency: "USD",
420
+ name: symbol,
421
+ portfolios: [],
422
+ watchlists: ["main"],
423
+ positions: [],
424
+ broker_contracts: [],
425
+ custom: {},
426
+ tags: [],
427
+ });
428
+ }
411
429
 
412
430
  const events: string[] = [];
431
+ const cachedTargetRequests: string[] = [];
432
+ const financialRefreshes: string[] = [];
413
433
 
414
434
  await initializeAppState({
415
435
  config: quoteOnlyConfig,
@@ -417,23 +437,26 @@ describe("initializeAppState", () => {
417
437
  dataProvider: {
418
438
  id: "test-provider",
419
439
  name: "Test Provider",
420
- getCachedFinancialsForTargets: () => new Map([["AAPL", {
421
- annualStatements: [],
422
- quarterlyStatements: [],
423
- priceHistory: [],
424
- quote: {
425
- symbol: "AAPL",
426
- price: 200,
427
- currency: "USD",
428
- change: 1,
429
- changePercent: 0.5,
430
- marketCap: 2_000_000_000,
431
- lastUpdated: Date.now(),
432
- },
433
- fundamentals: {
434
- trailingPE: 25,
435
- },
436
- }]]),
440
+ getCachedFinancialsForTargets: (targets: Array<{ symbol: string }>) => {
441
+ cachedTargetRequests.push(...targets.map((target) => target.symbol));
442
+ return new Map(targets.map((target) => [target.symbol, {
443
+ annualStatements: [],
444
+ quarterlyStatements: [],
445
+ priceHistory: [],
446
+ quote: {
447
+ symbol: target.symbol,
448
+ price: 200,
449
+ currency: "USD",
450
+ change: 1,
451
+ changePercent: 0.5,
452
+ marketCap: 2_000_000_000,
453
+ lastUpdated: Date.now(),
454
+ },
455
+ fundamentals: {
456
+ trailingPE: 25,
457
+ },
458
+ }]));
459
+ },
437
460
  } as any,
438
461
  sessionSnapshot: {
439
462
  paneState: {
@@ -446,11 +469,11 @@ describe("initializeAppState", () => {
446
469
  activePanel: "left",
447
470
  statusBarVisible: true,
448
471
  openPaneIds: ["portfolio-list:main"],
449
- hydrationTargets: [{
450
- symbol: "AAPL",
472
+ hydrationTargets: hydrationSymbols.map((symbol) => ({
473
+ symbol,
451
474
  exchange: "NASDAQ",
452
475
  instrument: null,
453
- }],
476
+ })),
454
477
  exchangeCurrencies: [],
455
478
  savedAt: Date.now(),
456
479
  },
@@ -458,13 +481,17 @@ describe("initializeAppState", () => {
458
481
  primeCachedFinancials: (entries) => {
459
482
  events.push(`prime:${entries.map((entry) => entry.ticker.metadata.ticker).join(",")}`);
460
483
  },
461
- refreshTicker: () => {},
484
+ refreshTicker: (symbol) => { financialRefreshes.push(symbol); },
462
485
  refreshQuote: () => {},
463
486
  autoImportBrokerPositions: async () => {},
464
487
  });
465
488
 
466
- expect(events).toContain("prime:AAPL");
467
- expect(events.indexOf("prime:AAPL")).toBeLessThan(events.indexOf("SET_INITIALIZED"));
489
+ const primeEvent = events.find((event) => event.startsWith("prime:"));
490
+ expect(primeEvent).toContain("AAPL");
491
+ expect(primeEvent).toContain("T13");
492
+ expect(cachedTargetRequests).toContain("T13");
493
+ expect(financialRefreshes).not.toContain("T13");
494
+ expect(events.indexOf(primeEvent!)).toBeLessThan(events.indexOf("SET_INITIALIZED"));
468
495
 
469
496
  persistence.close();
470
497
  });
@@ -37,11 +37,11 @@ interface RefreshPlanEntry {
37
37
  }
38
38
 
39
39
  const MAX_BACKGROUND_WARMUP_TICKERS = 12;
40
- const PORTFOLIO_FINANCIAL_COLUMN_IDS = new Set([
41
- "market_cap",
40
+ const PORTFOLIO_FULL_SNAPSHOT_COLUMN_IDS = new Set([
42
41
  "pe",
43
42
  "forward_pe",
44
43
  "dividend_yield",
44
+ "sparkline",
45
45
  ]);
46
46
  const startupLog = debugLog.createLogger("startup");
47
47
 
@@ -157,7 +157,7 @@ function buildRefreshPlan(
157
157
  const columnIds = Array.isArray(instance.settings?.columnIds)
158
158
  ? instance.settings.columnIds.filter((value): value is string => typeof value === "string")
159
159
  : [];
160
- return columnIds.some((columnId) => PORTFOLIO_FINANCIAL_COLUMN_IDS.has(columnId))
160
+ return columnIds.some((columnId) => PORTFOLIO_FULL_SNAPSHOT_COLUMN_IDS.has(columnId))
161
161
  ? "financials"
162
162
  : "quote";
163
163
  };
@@ -216,26 +216,39 @@ function buildCachedFinancialTarget(ticker: TickerRecord): CachedFinancialsTarge
216
216
  async function resolveCachedFinancialPrimeEntries(
217
217
  refreshPlan: RefreshPlanEntry[],
218
218
  sessionSnapshot: AppSessionSnapshot | null | undefined,
219
+ tickerMap: Map<string, TickerRecord>,
219
220
  dataProvider: DataProvider,
220
221
  ): Promise<Array<{ ticker: TickerRecord; financials: TickerFinancials }>> {
221
222
  if (!dataProvider.getCachedFinancialsForTargets) return [];
222
223
 
223
- const sessionTargetsBySymbol = new Map<string, CachedFinancialsTarget>();
224
+ const targetEntriesBySymbol = new Map<string, { ticker: TickerRecord; target: CachedFinancialsTarget }>();
224
225
  for (const target of sessionSnapshot?.hydrationTargets ?? []) {
225
- sessionTargetsBySymbol.set(target.symbol.trim().toUpperCase(), target);
226
+ const symbol = target.symbol.trim().toUpperCase();
227
+ const ticker = tickerMap.get(symbol);
228
+ if (ticker) {
229
+ targetEntriesBySymbol.set(symbol, { ticker, target });
230
+ }
226
231
  }
227
232
 
228
- const financialEntries = refreshPlan.filter((entry) => entry.mode === "financials");
229
- if (financialEntries.length === 0) return [];
233
+ for (const entry of refreshPlan.filter((entry) => entry.mode === "financials")) {
234
+ const symbol = entry.ticker.metadata.ticker.trim().toUpperCase();
235
+ if (!targetEntriesBySymbol.has(symbol)) {
236
+ targetEntriesBySymbol.set(symbol, {
237
+ ticker: entry.ticker,
238
+ target: buildCachedFinancialTarget(entry.ticker),
239
+ });
240
+ }
241
+ }
230
242
 
243
+ const targetEntries = [...targetEntriesBySymbol.values()];
244
+ if (targetEntries.length === 0) return [];
231
245
  const cachedFinancials = await dataProvider.getCachedFinancialsForTargets(
232
- financialEntries.map(({ ticker }) => (
233
- sessionTargetsBySymbol.get(ticker.metadata.ticker.trim().toUpperCase()) ?? buildCachedFinancialTarget(ticker)
234
- )),
246
+ targetEntries.map((entry) => entry.target),
247
+ { includeStaleQuotes: true },
235
248
  );
236
249
  const primedEntries: Array<{ ticker: TickerRecord; financials: TickerFinancials }> = [];
237
250
 
238
- for (const entry of financialEntries) {
251
+ for (const entry of targetEntries) {
239
252
  const cached = cachedFinancials.get(entry.ticker.metadata.ticker.trim().toUpperCase());
240
253
  if (cached) {
241
254
  primedEntries.push({ ticker: entry.ticker, financials: cached });
@@ -347,8 +360,11 @@ export async function initializeAppState({
347
360
  if (primeCachedFinancials) {
348
361
  const cachedPrimeEntries = await measurePerfAsync(
349
362
  "startup.resolve-cached-financial-prime",
350
- () => resolveCachedFinancialPrimeEntries(refreshPlan, sessionSnapshot, dataProvider),
351
- { financialRefreshCount: refreshPlan.filter((entry) => entry.mode === "financials").length },
363
+ () => resolveCachedFinancialPrimeEntries(refreshPlan, sessionSnapshot, tickerMap, dataProvider),
364
+ {
365
+ financialRefreshCount: refreshPlan.filter((entry) => entry.mode === "financials").length,
366
+ sessionHydrationTargetCount: sessionSnapshot?.hydrationTargets.length ?? 0,
367
+ },
352
368
  );
353
369
  if (cachedPrimeEntries.length > 0) {
354
370
  measurePerf("startup.prime-cached-financials", () => {
@@ -329,6 +329,64 @@ describe("pane state updates", () => {
329
329
 
330
330
  expect(next).toBe(initial);
331
331
  });
332
+
333
+ test("keeps pane runtime state scoped to each saved layout", () => {
334
+ let state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
335
+ const originalLayoutIndex = state.config.activeLayoutIndex;
336
+
337
+ state = appReducer(state, {
338
+ type: "UPDATE_PANE_STATE",
339
+ paneId: "ticker-detail:main",
340
+ patch: { activeTabId: "chart" },
341
+ });
342
+ state = appReducer(state, { type: "DUPLICATE_LAYOUT", index: originalLayoutIndex });
343
+ const duplicateLayoutIndex = state.config.activeLayoutIndex;
344
+
345
+ state = appReducer(state, {
346
+ type: "UPDATE_PANE_STATE",
347
+ paneId: "ticker-detail:main",
348
+ patch: { activeTabId: "financials" },
349
+ });
350
+
351
+ expect(state.config.layouts[duplicateLayoutIndex]?.paneState?.["ticker-detail:main"]?.activeTabId).toBe("financials");
352
+
353
+ state = appReducer(state, { type: "SWITCH_LAYOUT", index: originalLayoutIndex });
354
+ expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("chart");
355
+ expect(state.config.layouts[originalLayoutIndex]?.paneState?.["ticker-detail:main"]?.activeTabId).toBe("chart");
356
+
357
+ state = appReducer(state, { type: "SWITCH_LAYOUT", index: duplicateLayoutIndex });
358
+ expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("financials");
359
+ });
360
+
361
+ test("hydrates saved layout pane state before legacy session pane state", () => {
362
+ const config = createDefaultConfig("/tmp/gloomberb-test");
363
+ config.layouts[config.activeLayoutIndex] = {
364
+ ...config.layouts[config.activeLayoutIndex]!,
365
+ paneState: {
366
+ "ticker-detail:main": { activeTabId: "chart" },
367
+ },
368
+ focusedPaneId: "ticker-detail:main",
369
+ activePanel: "right",
370
+ };
371
+ const sessionSnapshot: AppSessionSnapshot = {
372
+ paneState: {
373
+ "ticker-detail:main": { activeTabId: "overview" },
374
+ },
375
+ focusedPaneId: "portfolio-list:main",
376
+ activePanel: "left",
377
+ statusBarVisible: true,
378
+ openPaneIds: ["portfolio-list:main", "ticker-detail:main"],
379
+ hydrationTargets: [],
380
+ exchangeCurrencies: ["USD"],
381
+ savedAt: Date.now(),
382
+ };
383
+
384
+ const state = createInitialState(config, sessionSnapshot);
385
+
386
+ expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("chart");
387
+ expect(state.focusedPaneId).toBe("ticker-detail:main");
388
+ expect(state.activePanel).toBe("right");
389
+ });
332
390
  });
333
391
 
334
392
  describe("quote merging", () => {
@@ -487,6 +545,61 @@ describe("quote merging", () => {
487
545
  });
488
546
 
489
547
  describe("layout focus fallback", () => {
548
+ test("switching to an old layout without focused metadata uses that layout's top floating pane", () => {
549
+ const config = createDefaultConfig("/tmp/gloomberb-test");
550
+ const targetLayout = {
551
+ ...cloneLayout(config.layout),
552
+ dockRoot: null,
553
+ floating: [
554
+ { instanceId: "chat:main", x: 4, y: 2, width: 36, height: 10, zIndex: 70 },
555
+ { instanceId: "ticker-detail:main", x: 12, y: 4, width: 36, height: 10, zIndex: 95 },
556
+ ],
557
+ };
558
+ config.layouts = [
559
+ { name: "Current", layout: cloneLayout(config.layout), paneState: {} },
560
+ { name: "Floating", layout: cloneLayout(targetLayout), paneState: {} },
561
+ ];
562
+ config.activeLayoutIndex = 0;
563
+
564
+ let state = createInitialState(config);
565
+ state = appReducer(state, { type: "FOCUS_PANE", paneId: "chat:main" });
566
+ state = appReducer(state, { type: "SWITCH_LAYOUT", index: 1 });
567
+
568
+ const chatEntry = state.config.layout.floating.find((entry) => entry.instanceId === "chat:main");
569
+ const tickerEntry = state.config.layout.floating.find((entry) => entry.instanceId === "ticker-detail:main");
570
+ expect(state.focusedPaneId).toBe("ticker-detail:main");
571
+ expect((tickerEntry?.zIndex ?? 0) > (chatEntry?.zIndex ?? 0)).toBe(true);
572
+ });
573
+
574
+ test("switching layouts raises the saved focused floating pane above stale z-order", () => {
575
+ const config = createDefaultConfig("/tmp/gloomberb-test");
576
+ const targetLayout = {
577
+ ...cloneLayout(config.layout),
578
+ dockRoot: null,
579
+ floating: [
580
+ { instanceId: "chat:main", x: 4, y: 2, width: 36, height: 10, zIndex: 70 },
581
+ { instanceId: "ticker-detail:main", x: 12, y: 4, width: 36, height: 10, zIndex: 95 },
582
+ ],
583
+ };
584
+ config.layouts = [
585
+ { name: "Current", layout: cloneLayout(config.layout), paneState: {} },
586
+ {
587
+ name: "Floating",
588
+ layout: cloneLayout(targetLayout),
589
+ paneState: {},
590
+ focusedPaneId: "chat:main",
591
+ },
592
+ ];
593
+ config.activeLayoutIndex = 0;
594
+
595
+ const state = appReducer(createInitialState(config), { type: "SWITCH_LAYOUT", index: 1 });
596
+
597
+ const chatEntry = state.config.layout.floating.find((entry) => entry.instanceId === "chat:main");
598
+ const tickerEntry = state.config.layout.floating.find((entry) => entry.instanceId === "ticker-detail:main");
599
+ expect(state.focusedPaneId).toBe("chat:main");
600
+ expect((chatEntry?.zIndex ?? 0) > (tickerEntry?.zIndex ?? 0)).toBe(true);
601
+ });
602
+
490
603
  test("starts with the top floating pane focused when no session pane is active", () => {
491
604
  const config = createDefaultConfig("/tmp/gloomberb-test");
492
605
  const layout = {
@@ -37,6 +37,7 @@ import {
37
37
  type PaneRuntimeState,
38
38
  resolveCollectionForPane,
39
39
  resolveTickerForPane,
40
+ syncConfigActiveLayoutState,
40
41
  } from "../core/state/app-state";
41
42
  import type { AppAction, AppState } from "../core/state/app-state";
42
43
  import { scheduleConfigSave } from "./config-save-scheduler";
@@ -50,6 +51,7 @@ export {
50
51
  getPaneState,
51
52
  resolveCollectionForPane,
52
53
  resolveFinancialsForPane,
54
+ syncConfigActiveLayoutState,
53
55
  resolveTickerFileForPane,
54
56
  resolveTickerForPane,
55
57
  } from "../core/state/app-state";
@@ -283,10 +285,12 @@ export function usePaneSettingValue<T>(
283
285
  ? (nextValue as (previousValue: T) => T)(currentValue)
284
286
  : nextValue;
285
287
  const layout = setPaneSetting(currentState.config.layout, scopedPaneId, key, resolved);
286
- const layouts = currentState.config.layouts.map((savedLayout, index) => (
287
- index === currentState.config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
288
- ));
289
- const nextConfig = { ...currentState.config, layout, layouts };
288
+ const nextConfig = syncConfigActiveLayoutState(
289
+ { ...currentState.config, layout },
290
+ currentState.paneState,
291
+ currentState.focusedPaneId,
292
+ currentState.activePanel,
293
+ );
290
294
  dispatch({ type: "SET_CONFIG", config: nextConfig });
291
295
  scheduleConfigSave(nextConfig);
292
296
  }, [dispatch, fallback, key, scopedPaneId, stateRef]);
@@ -24,7 +24,10 @@ function createTicker(symbol: string, exchange = "NASDAQ"): TickerRecord {
24
24
  describe("session persistence", () => {
25
25
  test("builds a working-set snapshot from runtime state", () => {
26
26
  const config = createDefaultConfig("/tmp/gloomberb-test");
27
- const tickers = new Map<string, TickerRecord>([["AAPL", createTicker("AAPL")]]);
27
+ const tickers = new Map<string, TickerRecord>([
28
+ ["AAPL", createTicker("AAPL")],
29
+ ["MSFT", createTicker("MSFT")],
30
+ ]);
28
31
  const financials = new Map<string, TickerFinancials>([["AAPL", {
29
32
  annualStatements: [],
30
33
  quarterlyStatements: [],
@@ -61,8 +64,7 @@ describe("session persistence", () => {
61
64
  });
62
65
 
63
66
  expect(snapshot.focusedPaneId).toBe("ticker-detail:main");
64
- expect(snapshot.hydrationTargets).toHaveLength(1);
65
- expect(snapshot.hydrationTargets[0]?.symbol).toBe("AAPL");
67
+ expect(snapshot.hydrationTargets.map((target) => target.symbol)).toEqual(["AAPL", "MSFT"]);
66
68
  expect(snapshot.exchangeCurrencies).toContain("JPY");
67
69
  expect(snapshot.paneState["portfolio-list:main"]).toEqual({
68
70
  cursorSymbol: "AAPL",
@@ -152,6 +152,9 @@ export function useInlineTickers(texts: readonly string[]): {
152
152
  return {
153
153
  symbol: ticker.metadata.ticker,
154
154
  exchange: ticker.metadata.exchange,
155
+ surface: "inline" as const,
156
+ visible: true,
157
+ weight: 40,
155
158
  context: instrument
156
159
  ? {
157
160
  brokerId: instrument.brokerId,
@@ -22,6 +22,7 @@ export function buildQuoteStreamSubscriptionKey(target: QuoteSubscriptionTarget)
22
22
  ?? target.context?.instrument?.localSymbol
23
23
  ?? target.context?.instrument?.symbol
24
24
  ?? "";
25
+ const weight = Number.isFinite(target.weight) ? String(target.weight) : "";
25
26
  return [
26
27
  target.symbol,
27
28
  target.exchange ?? "",
@@ -29,6 +30,10 @@ export function buildQuoteStreamSubscriptionKey(target: QuoteSubscriptionTarget)
29
30
  target.context?.brokerInstanceId ?? "",
30
31
  contractKey,
31
32
  target.route ?? "auto",
33
+ target.surface ?? "",
34
+ target.visible ? "visible" : "",
35
+ target.selected ? "selected" : "",
36
+ weight,
32
37
  ].join("|");
33
38
  }
34
39
 
@@ -68,6 +73,12 @@ export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
68
73
  brokerInstanceId: target.context?.brokerInstanceId,
69
74
  instrument: target.context?.instrument ?? null,
70
75
  },
76
+ priority: {
77
+ surface: target.surface,
78
+ visible: target.visible,
79
+ selected: target.selected,
80
+ weight: target.weight,
81
+ },
71
82
  })));
72
83
  return () => {
73
84
  quoteStreamLog.info("unsubscribe", {
@@ -73,21 +73,20 @@ export function syncTheme(id: string): void {
73
73
 
74
74
  export { blendHex } from "./color-utils";
75
75
 
76
- const COMPARISON_SERIES_COLORS = [
77
- "#5bc0eb",
78
- "#f6ae2d",
79
- "#f26419",
80
- "#6f2dbd",
81
- "#00a896",
82
- "#ef476f",
83
- "#118ab2",
84
- "#8ac926",
85
- "#ff7f51",
86
- "#4361ee",
87
- ] as const;
88
-
89
76
  export function getComparisonSeriesColor(index: number): string {
90
- return COMPARISON_SERIES_COLORS[((index % COMPARISON_SERIES_COLORS.length) + COMPARISON_SERIES_COLORS.length) % COMPARISON_SERIES_COLORS.length]!;
77
+ const seriesColors = [
78
+ colors.borderFocused,
79
+ colors.warning,
80
+ colors.positive,
81
+ colors.negative,
82
+ colors.textBright,
83
+ colors.neutral,
84
+ blendHex(colors.borderFocused, colors.positive, 0.45),
85
+ blendHex(colors.warning, colors.negative, 0.35),
86
+ blendHex(colors.textBright, colors.positive, 0.35),
87
+ blendHex(colors.borderFocused, colors.negative, 0.35),
88
+ ];
89
+ return seriesColors[((index % seriesColors.length) + seriesColors.length) % seriesColors.length]!;
91
90
  }
92
91
 
93
92
  /** Returns a hover background color derived from bg and selected */
@@ -1,8 +1,8 @@
1
1
  import type { Portfolio, Watchlist } from "./ticker";
2
2
 
3
- export const CURRENT_CONFIG_VERSION = 15;
3
+ export const CURRENT_CONFIG_VERSION = 16;
4
4
 
5
- export type DefaultChartRenderMode = "area" | "line" | "candles" | "ohlc";
5
+ export type DefaultChartRenderMode = "area" | "line" | "candles" | "ohlc" | "hlc";
6
6
  export type ChartRendererPreference = "auto" | "kitty" | "braille";
7
7
 
8
8
  export interface ChartPreferences {
@@ -108,8 +108,12 @@ export interface LayoutConfig {
108
108
  }
109
109
 
110
110
  export interface SavedLayout {
111
+ id?: string;
111
112
  name: string;
112
113
  layout: LayoutConfig;
114
+ paneState?: Record<string, Record<string, unknown>>;
115
+ focusedPaneId?: string | null;
116
+ activePanel?: "left" | "right";
113
117
  }
114
118
 
115
119
  export interface AppConfig {
@@ -154,6 +158,7 @@ export const DEFAULT_COLUMNS: ColumnConfig[] = [
154
158
 
155
159
  export const DEFAULT_PORTFOLIO_COLUMN_IDS = [
156
160
  ...DEFAULT_COLUMNS.map((column) => column.id),
161
+ "sparkline",
157
162
  "shares",
158
163
  "avg_cost",
159
164
  "cost_basis",
@@ -585,8 +590,8 @@ export function createDefaultConfig(dataDir: string): AppConfig {
585
590
  watchlists: [{ id: "watchlist", name: "Watchlist" }],
586
591
  layout,
587
592
  layouts: [
588
- { name: "Home", layout: cloneLayout(layout) },
589
- { name: "Monitor", layout: cloneLayout(DEFAULT_MONITOR_LAYOUT) },
593
+ { name: "Home", layout: cloneLayout(layout), paneState: {} },
594
+ { name: "Monitor", layout: cloneLayout(DEFAULT_MONITOR_LAYOUT), paneState: {} },
590
595
  ],
591
596
  activeLayoutIndex: 0,
592
597
  brokerInstances: [],
@@ -90,6 +90,10 @@ export interface QuoteSubscriptionTarget {
90
90
  exchange?: string;
91
91
  context?: MarketDataRequestContext;
92
92
  route?: "auto" | "provider" | "broker";
93
+ surface?: "portfolio" | "watchlist" | "detail" | "monitor" | "inline" | "screener" | "unknown";
94
+ visible?: boolean;
95
+ selected?: boolean;
96
+ weight?: number;
93
97
  }
94
98
 
95
99
  export interface QuoteBatchResult {
@@ -111,7 +115,7 @@ export interface AssetDataProvider {
111
115
  readonly cachePolicy?: CachePolicyMap;
112
116
 
113
117
  canProvide?(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<boolean> | boolean;
114
- getCachedFinancialsForTargets?(targets: CachedFinancialsTarget[], options?: { allowExpired?: boolean }): Map<string, TickerFinancials> | Promise<Map<string, TickerFinancials>>;
118
+ getCachedFinancialsForTargets?(targets: CachedFinancialsTarget[], options?: { allowExpired?: boolean; includeStaleQuotes?: boolean }): Map<string, TickerFinancials> | Promise<Map<string, TickerFinancials>>;
115
119
  getQuotesBatch?(targets: QuoteSubscriptionTarget[], options?: { forceRefresh?: boolean }): Promise<QuoteBatchResult[]>;
116
120
  getTickerFinancialsBatch?(targets: CachedFinancialsTarget[], options?: { forceRefresh?: boolean }): Promise<TickerFinancialsBatchResult[]>;
117
121
  getTickerFinancials(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<TickerFinancials>;
@@ -32,6 +32,7 @@ export interface Quote {
32
32
  volume?: number;
33
33
  name?: string;
34
34
  lastUpdated: number; // timestamp ms
35
+ receivedAt?: number; // local receipt timestamp ms for streamed/display freshness
35
36
  exchangeName?: string;
36
37
  fullExchangeName?: string;
37
38
  listingExchangeName?: string;
@@ -110,6 +110,7 @@ export type PaneSettingField =
110
110
 
111
111
  export interface PaneSettingsDef {
112
112
  title?: string;
113
+ values?: Record<string, unknown>;
113
114
  fields: PaneSettingField[];
114
115
  }
115
116
 
@@ -406,6 +407,12 @@ export interface BrokerInstanceUpdateOptions {
406
407
  replaceConfig?: boolean;
407
408
  }
408
409
 
410
+ export interface PinTickerOptions {
411
+ floating?: boolean;
412
+ paneType?: string;
413
+ forceNewPane?: boolean;
414
+ }
415
+
409
416
  export interface GloomPluginContext {
410
417
  registerPane(pane: PaneDef): void;
411
418
  registerPaneTemplate(template: PaneTemplateDef): void;
@@ -448,7 +455,7 @@ export interface GloomPluginContext {
448
455
  createPaneFromTemplate(templateId: string, options?: PaneTemplateCreateOptions): void;
449
456
  hidePane(paneId: string): void;
450
457
  focusPane(paneId: string): void;
451
- pinTicker(symbol: string, options?: { floating?: boolean; paneType?: string }): void;
458
+ pinTicker(symbol: string, options?: PinTickerOptions): void;
452
459
  navigateTicker(symbol: string, options?: { sourcePaneId?: string | null }): void;
453
460
  openPaneSettings(paneId?: string): void;
454
461