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
@@ -5,8 +5,9 @@ import type { GloomPlugin } from "../../types/plugin";
5
5
  import type { OptionContract, OptionsChain } from "../../types/financials";
6
6
  import { usePaneTicker } from "../../state/app-context";
7
7
  import { blendHex, colors, hoverBg } from "../../theme/colors";
8
- import { blendForContrast, contrastRatio, higherContrast } from "../../theme/color-utils";
8
+ import { blendForContrast, contrastRatio } from "../../theme/color-utils";
9
9
  import { formatCompact, formatNumber } from "../../utils/format";
10
+ import { isPlainKey } from "../../utils/keyboard";
10
11
  import { formatMarketPrice } from "../../utils/market-format";
11
12
  import { formatExpDate, resolveOptionsTarget } from "../../utils/options";
12
13
  import { useOptionsQuery, useResolvedEntryValue } from "../../market-data/hooks";
@@ -48,15 +49,6 @@ type OptionColorRole = "call" | "put" | "price" | "activity" | "iv" | "strike";
48
49
 
49
50
  const OPTION_TEXT_MIN_CONTRAST = 4.5;
50
51
 
51
- const OPTION_BASE_COLORS: Record<OptionColorRole, string> = {
52
- call: "#5ed69a",
53
- put: "#ff9c7a",
54
- price: "#dfc05b",
55
- activity: "#35a7d6",
56
- iv: "#8bd878",
57
- strike: "#8fb7ff",
58
- };
59
-
60
52
  const OPTION_COLUMNS: Array<Omit<OptionColumn, "headerColor">> = [
61
53
  { id: "callOpenInterest", label: "C OI", width: 6, align: "right" },
62
54
  { id: "callVolume", label: "C VOL", width: 6, align: "right" },
@@ -227,7 +219,7 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
227
219
  return true;
228
220
  }
229
221
 
230
- if (event.name === "j" || event.name === "down") {
222
+ if (isPlainKey(event, "j", "down")) {
231
223
  if (strikes.length === 0) return true;
232
224
  event.preventDefault?.();
233
225
  event.stopPropagation?.();
@@ -236,7 +228,7 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
236
228
  setStrikeIdx((i) => Math.min(i + 1, strikes.length - 1));
237
229
  return true;
238
230
  }
239
- if (event.name === "k" || event.name === "up") {
231
+ if (isPlainKey(event, "k", "up")) {
240
232
  if (strikes.length === 0) return true;
241
233
  event.preventDefault?.();
242
234
  event.stopPropagation?.();
@@ -404,14 +396,13 @@ function mostReadableColor(surface: string, candidates: readonly string[]): stri
404
396
  }
405
397
 
406
398
  export function optionRoleColor(role: OptionColorRole, surface: string): string {
407
- const preferred = OPTION_BASE_COLORS[role];
408
- const themeColor = optionRoleThemeColor(role);
399
+ const preferred = optionRoleThemeColor(role);
409
400
  const fallback = mostReadableColor(surface, [
410
401
  preferred,
411
- themeColor,
412
402
  colors.text,
413
403
  colors.textBright,
414
- higherContrast("#ffffff", "#000000", surface),
404
+ colors.selectedText,
405
+ colors.neutral,
415
406
  ]);
416
407
  return blendForContrast(preferred, surface, fallback, OPTION_TEXT_MIN_CONTRAST);
417
408
  }
@@ -421,7 +412,8 @@ export function optionMutedColor(surface: string): string {
421
412
  colors.textDim,
422
413
  colors.text,
423
414
  colors.textBright,
424
- higherContrast("#ffffff", "#000000", surface),
415
+ colors.selectedText,
416
+ colors.neutral,
425
417
  ]);
426
418
  return blendForContrast(colors.textDim, surface, fallback, OPTION_TEXT_MIN_CONTRAST);
427
419
  }
@@ -23,6 +23,7 @@ import type { CliCommandContext, CliCommandDef } from "../../../../types/plugin"
23
23
  import type { TickerRecord } from "../../../../types/ticker";
24
24
  import {
25
25
  addTickerToPortfolio,
26
+ addTickerToWatchlist,
26
27
  createManualPortfolio,
27
28
  deleteManualPortfolio,
28
29
  findPortfolio,
@@ -415,7 +416,7 @@ async function deleteWatchlist(name: string, ctx: CliCommandContext) {
415
416
  persistence.close();
416
417
  }
417
418
 
418
- async function addTickerToWatchlist(watchlistName: string, symbol: string, ctx: CliCommandContext) {
419
+ async function addTickerToWatchlistCommand(watchlistName: string, symbol: string, ctx: CliCommandContext) {
419
420
  const { config, store, dataProvider, persistence } = await ctx.initMarketData();
420
421
  const watchlist = findWatchlist(config, watchlistName);
421
422
  if (!watchlist) {
@@ -424,19 +425,14 @@ async function addTickerToWatchlist(watchlistName: string, symbol: string, ctx:
424
425
 
425
426
  try {
426
427
  const ticker = await resolveTickerForCli(symbol, store, dataProvider);
427
- if (ticker.metadata.watchlists.includes(watchlist.id)) {
428
+ const result = addTickerToWatchlist(ticker, watchlist.id);
429
+ if (!result.changed) {
428
430
  console.log(cliStyles.warning(`${ticker.metadata.ticker} is already in "${watchlist.name}".`));
429
431
  persistence.close();
430
432
  return;
431
433
  }
432
434
 
433
- const nextTicker: TickerRecord = {
434
- ...ticker,
435
- metadata: {
436
- ...ticker.metadata,
437
- watchlists: [...ticker.metadata.watchlists, watchlist.id],
438
- },
439
- };
435
+ const nextTicker = result.ticker;
440
436
  await store.saveTicker(nextTicker);
441
437
  console.log(cliStyles.success(`Added ${nextTicker.metadata.ticker} to "${watchlist.name}".`));
442
438
  if (nextTicker.metadata.name) {
@@ -644,7 +640,7 @@ export const watchlistCliCommand: CliCommandDef = {
644
640
  const symbol = args.at(-1);
645
641
  const name = args.slice(1, -1).join(" ");
646
642
  if (!name || !symbol) ctx.fail("Usage: gloomberb watchlist add <watchlist> <ticker>");
647
- await addTickerToWatchlist(name!, symbol!, ctx);
643
+ await addTickerToWatchlistCommand(name!, symbol!, ctx);
648
644
  return;
649
645
  }
650
646
 
@@ -3,11 +3,12 @@ import type { ColumnConfig } from "../../../types/config";
3
3
  import type { TickerFinancials } from "../../../types/financials";
4
4
  import type { TickerRecord } from "../../../types/ticker";
5
5
  import { priceColor } from "../../../theme/colors";
6
- import { clampQuoteTimestamp, formatQuoteAgeWithSource } from "../../../utils/quote-time";
6
+ import { formatQuoteAgeWithSource, resolveQuoteAgeTimestamp } from "../../../utils/quote-time";
7
7
  import { convertCurrency, formatCompact, formatNumber, formatPercentRaw } from "../../../utils/format";
8
8
  import { formatMarketCost, formatMarketPrice, formatMarketQuantity, formatSignedMarketPrice, type MarketFormatOptions } from "../../../utils/market-format";
9
9
  import { getActiveQuoteDisplay, marketStateDot, type ActiveQuoteDisplay } from "../../../utils/market-status";
10
10
  import { formatOptionTicker } from "../../../utils/options";
11
+ import { PRICE_SPARKLINE_COLUMN_ID } from "../../../components/price-sparkline-view";
11
12
 
12
13
  export interface ColumnContext {
13
14
  activeTab?: string;
@@ -293,6 +294,8 @@ export function getColumnValue(
293
294
  return { text: "—" };
294
295
  case "latency":
295
296
  return { text: formatQuoteAgeWithSource(quote, ctx.now) };
297
+ case PRICE_SPARKLINE_COLUMN_ID:
298
+ return { text: "" };
296
299
  default:
297
300
  return { text: "—" };
298
301
  }
@@ -386,7 +389,15 @@ export function getSortValue(
386
389
  }
387
390
  return null;
388
391
  case "latency":
389
- return quote?.lastUpdated != null ? ctx.now - (clampQuoteTimestamp(quote.lastUpdated, ctx.now) ?? ctx.now) : null;
392
+ return quote ? ctx.now - (resolveQuoteAgeTimestamp(quote, ctx.now) ?? ctx.now) : null;
393
+ case PRICE_SPARKLINE_COLUMN_ID: {
394
+ const values = (financials?.priceHistory ?? [])
395
+ .map((point) => point.close)
396
+ .filter((value) => Number.isFinite(value));
397
+ const first = values[0];
398
+ const last = values.at(-1);
399
+ return first != null && last != null && first !== 0 ? ((last - first) / Math.abs(first)) * 100 : null;
400
+ }
390
401
  default:
391
402
  return null;
392
403
  }
@@ -121,6 +121,23 @@ export function addTickerToPortfolio(
121
121
  };
122
122
  }
123
123
 
124
+ export function addTickerToWatchlist(
125
+ ticker: TickerRecord,
126
+ watchlistId: string,
127
+ ): { changed: boolean; ticker: TickerRecord } {
128
+ if (ticker.metadata.watchlists.includes(watchlistId)) {
129
+ return { changed: false, ticker };
130
+ }
131
+
132
+ return {
133
+ changed: true,
134
+ ticker: replaceTickerMetadata(ticker, {
135
+ ...ticker.metadata,
136
+ watchlists: [...ticker.metadata.watchlists, watchlistId],
137
+ }),
138
+ };
139
+ }
140
+
124
141
  export function removeTickerFromPortfolio(
125
142
  ticker: TickerRecord,
126
143
  portfolioId: string,
@@ -43,20 +43,50 @@ import {
43
43
  } from "./settings";
44
44
  import { formatPercentRaw } from "../../../utils/format";
45
45
  import { getMostRecentQuoteUpdate } from "../../../utils/quote-time";
46
+ import { isQuoteStaleForCurrentSession } from "../../../utils/quote-freshness";
46
47
  import { priceColor } from "../../../theme/colors";
47
48
  import { PortfolioTickerTable, type QuoteFlashDirection } from "./table";
48
49
  import { useThrottledCursorSymbol } from "./use-throttled-cursor-symbol";
50
+ import { isManualPortfolio } from "./mutations";
51
+ import { QuickAddTickerInput, type QuickAddCollectionKind } from "./quick-add";
52
+ import { PRICE_SPARKLINE_COLUMN_ID } from "../../../components/price-sparkline-view";
49
53
 
50
54
  const VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS = 5 * 60_000;
51
55
  const VISIBLE_FINANCIAL_WARMUP_DELAY_MS = 350;
56
+ const VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT = 3;
52
57
  const STREAM_OVERSCAN_ROWS = 6;
58
+ const FUNDAMENTAL_COLUMN_IDS = new Set(["pe", "forward_pe", "dividend_yield"]);
53
59
  const sortValueCache = createRowValueCache<string, ReturnType<typeof getSortValue>>(5000);
54
60
 
55
- function needsVisibleFinancialWarmup(ticker: TickerRecord, financials: TickerFinancials | undefined): boolean {
61
+ interface VisibleWarmupRequirements {
62
+ fundamentals: boolean;
63
+ priceHistory: boolean;
64
+ }
65
+
66
+ function resolveVisibleWarmupRequirements(columns: ColumnConfig[]): VisibleWarmupRequirements {
67
+ return {
68
+ fundamentals: columns.some((column) => FUNDAMENTAL_COLUMN_IDS.has(column.id)),
69
+ priceHistory: columns.some((column) => column.id === PRICE_SPARKLINE_COLUMN_ID),
70
+ };
71
+ }
72
+
73
+ function visibleWarmupKey(kind: "quote" | "snapshot", ticker: TickerRecord): string {
74
+ return `${kind}:${ticker.metadata.ticker}:${ticker.metadata.exchange ?? ""}`;
75
+ }
76
+
77
+ function needsVisibleQuoteWarmup(ticker: TickerRecord, financials: TickerFinancials | undefined, now = Date.now()): boolean {
78
+ return !financials?.quote || isQuoteStaleForCurrentSession(financials.quote, now);
79
+ }
80
+
81
+ function needsVisibleSnapshotWarmup(
82
+ ticker: TickerRecord,
83
+ financials: TickerFinancials | undefined,
84
+ requirements: VisibleWarmupRequirements,
85
+ ): boolean {
56
86
  if (ticker.metadata.assetCategory === "OPT") return false;
57
- if (!financials) return true;
58
- if (Object.keys(financials.fundamentals ?? {}).length === 0) return true;
59
- return financials.priceHistory.length === 0;
87
+ if (!financials?.quote) return false;
88
+ if (requirements.fundamentals && Object.keys(financials.fundamentals ?? {}).length === 0) return true;
89
+ return requirements.priceHistory && financials.priceHistory.length === 0;
60
90
  }
61
91
 
62
92
  export function selectStreamTickers(
@@ -209,6 +239,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
209
239
  const [now, setNow] = useState(Date.now());
210
240
  const [flashSymbols, setFlashSymbols] = useState<Map<string, QuoteFlashDirection>>(new Map());
211
241
  const [streamWindow, setStreamWindow] = useState({ start: 0, end: 24 });
242
+ const [quickAddFocused, setQuickAddFocused] = useState(false);
212
243
 
213
244
  const previousPricesRef = useRef<Map<string, number>>(new Map());
214
245
  const mountedRef = useRef(true);
@@ -235,6 +266,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
235
266
  );
236
267
  const activeCollectionId = resolveActiveCollectionId(currentCollectionId, visibleCollections);
237
268
  const isPortfolioTab = getCollectionTypeFromConfig(config, activeCollectionId) === "portfolio";
269
+ const activeCollectionEntry = visibleCollections.find((collection) => collection.id === activeCollectionId) ?? null;
238
270
  const currentPortfolio = useMemo(() => (
239
271
  isPortfolioTab
240
272
  ? config.portfolios.find((portfolio) => portfolio.id === activeCollectionId) ?? null
@@ -248,12 +280,12 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
248
280
  const marketFinancialsMap = useTickerFinancialsMap(tickers);
249
281
  const sharedCoordinator = getSharedMarketDataCoordinator();
250
282
  const financialsMap = useMemo(() => {
251
- const merged = sharedCoordinator ? new Map<string, TickerFinancials>() : new Map(cachedFinancials);
283
+ const merged = new Map(cachedFinancials);
252
284
  for (const [symbol, financials] of marketFinancialsMap) {
253
285
  merged.set(symbol, financials);
254
286
  }
255
287
  return merged;
256
- }, [cachedFinancials, marketFinancialsMap, sharedCoordinator]);
288
+ }, [cachedFinancials, marketFinancialsMap]);
257
289
 
258
290
  const accountStateInput = useMemo(() => ({ brokerAccounts, config }), [brokerAccounts, config]);
259
291
  const accountState = usePortfolioAccountState(currentPortfolio, accountStateInput);
@@ -261,6 +293,10 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
261
293
  () => resolveVisibleColumns(paneSettings.columnIds, isPortfolioTab),
262
294
  [isPortfolioTab, paneSettings.columnIds],
263
295
  );
296
+ const visibleWarmupRequirements = useMemo(
297
+ () => resolveVisibleWarmupRequirements(columns),
298
+ [columns],
299
+ );
264
300
 
265
301
  const trackedCurrencies = useMemo(
266
302
  () => buildTrackedCurrencies(tickers, financialsMap, accountState, config.baseCurrency),
@@ -334,6 +370,9 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
334
370
  flushCursorSymbol(ticker.metadata.ticker);
335
371
  openTickerFloating(ticker.metadata.ticker);
336
372
  }, [flushCursorSymbol, openTickerFloating]);
373
+ const handleTickerAdded = useCallback((symbol: string) => {
374
+ setCursorSymbol(symbol, { immediate: true });
375
+ }, [setCursorSymbol]);
337
376
 
338
377
  const handleTableKeyDown = useCallback((event: DataTableKeyEvent) => {
339
378
  if (!focused) return;
@@ -425,11 +464,28 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
425
464
  () => selectStreamTickers(sortedTickers, streamWindow, cursorSymbol),
426
465
  [cursorSymbol, sortedTickers, streamWindow],
427
466
  );
467
+ const priorityStreamSymbols = useMemo(
468
+ () => new Set(streamTickers.map((ticker) => ticker.metadata.ticker)),
469
+ [streamTickers],
470
+ );
471
+ const streamSurface = isPortfolioTab ? "portfolio" : "watchlist";
428
472
  const streamTargets = useMemo(() => (
429
- streamTickers
430
- .map((ticker) => quoteSubscriptionTargetFromTicker(ticker, ticker.metadata.ticker, "provider"))
473
+ sortedTickers
474
+ .map((ticker) => {
475
+ const target = quoteSubscriptionTargetFromTicker(ticker, ticker.metadata.ticker, "provider");
476
+ if (!target) return null;
477
+ const selected = ticker.metadata.ticker === cursorSymbol;
478
+ const visible = priorityStreamSymbols.has(ticker.metadata.ticker);
479
+ return {
480
+ ...target,
481
+ surface: streamSurface,
482
+ visible,
483
+ selected,
484
+ weight: selected ? 100 : visible ? 80 : 10,
485
+ };
486
+ })
431
487
  .filter((target): target is NonNullable<typeof target> => target != null)
432
- ), [streamTickers]);
488
+ ), [cursorSymbol, priorityStreamSymbols, sortedTickers, streamSurface]);
433
489
  const visibleFinancialTickers = useMemo(
434
490
  () => sortedTickers.slice(streamWindow.start, streamWindow.end),
435
491
  [sortedTickers, streamWindow.end, streamWindow.start],
@@ -441,31 +497,64 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
441
497
  if (!sharedCoordinator) return;
442
498
 
443
499
  const nowTimestamp = Date.now();
444
- const queue = visibleFinancialTickers.filter((ticker) => {
445
- const key = `${ticker.metadata.ticker}:${ticker.metadata.exchange ?? ""}`;
446
- if (warmupInFlightRef.current.has(key)) return false;
447
- if (nowTimestamp - (warmupAttemptRef.current.get(key) ?? 0) < VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS) return false;
448
- return needsVisibleFinancialWarmup(ticker, financialsMap.get(ticker.metadata.ticker));
449
- });
450
- if (queue.length === 0) return;
500
+ const quoteQueue: TickerRecord[] = [];
501
+ const snapshotQueue: TickerRecord[] = [];
502
+ for (const ticker of visibleFinancialTickers) {
503
+ const financials = financialsMap.get(ticker.metadata.ticker);
504
+ const quoteKey = visibleWarmupKey("quote", ticker);
505
+ if (
506
+ needsVisibleQuoteWarmup(ticker, financials, nowTimestamp)
507
+ && !warmupInFlightRef.current.has(quoteKey)
508
+ && nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >= VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS
509
+ ) {
510
+ quoteQueue.push(ticker);
511
+ continue;
512
+ }
513
+
514
+ const snapshotKey = visibleWarmupKey("snapshot", ticker);
515
+ if (
516
+ needsVisibleSnapshotWarmup(ticker, financials, visibleWarmupRequirements)
517
+ && !warmupInFlightRef.current.has(snapshotKey)
518
+ && nowTimestamp - (warmupAttemptRef.current.get(snapshotKey) ?? 0) >= VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS
519
+ ) {
520
+ snapshotQueue.push(ticker);
521
+ }
522
+ }
523
+ const limitedSnapshotQueue = snapshotQueue.slice(0, VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT);
524
+ if (quoteQueue.length === 0 && limitedSnapshotQueue.length === 0) return;
451
525
 
452
526
  let cancelled = false;
453
527
  const runBatch = async (): Promise<void> => {
454
- const entries = queue.flatMap((ticker) => {
528
+ const quoteEntries = quoteQueue.flatMap((ticker) => {
529
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
530
+ if (!instrument) return [];
531
+ const key = visibleWarmupKey("quote", ticker);
532
+ warmupInFlightRef.current.add(key);
533
+ warmupAttemptRef.current.set(key, nowTimestamp);
534
+ return [{ key, instrument }];
535
+ });
536
+ const snapshotEntries = limitedSnapshotQueue.flatMap((ticker) => {
455
537
  const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
456
538
  if (!instrument) return [];
457
- const key = `${ticker.metadata.ticker}:${ticker.metadata.exchange ?? ""}`;
539
+ const key = visibleWarmupKey("snapshot", ticker);
458
540
  warmupInFlightRef.current.add(key);
459
541
  warmupAttemptRef.current.set(key, nowTimestamp);
460
542
  return [{ key, instrument }];
461
543
  });
462
- if (entries.length === 0) return;
544
+ if (quoteEntries.length === 0 && snapshotEntries.length === 0) return;
463
545
  try {
464
- await sharedCoordinator.loadSnapshotsBatch(entries.map((entry) => entry.instrument));
546
+ await Promise.allSettled([
547
+ quoteEntries.length > 0
548
+ ? sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument))
549
+ : Promise.resolve(),
550
+ snapshotEntries.length > 0
551
+ ? sharedCoordinator.loadSnapshotsBatch(snapshotEntries.map((entry) => entry.instrument))
552
+ : Promise.resolve(),
553
+ ]);
465
554
  } catch {
466
555
  // Best-effort warmup for visible rows only.
467
556
  } finally {
468
- for (const entry of entries) {
557
+ for (const entry of [...quoteEntries, ...snapshotEntries]) {
469
558
  warmupInFlightRef.current.delete(entry.key);
470
559
  }
471
560
  }
@@ -479,7 +568,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
479
568
  cancelled = true;
480
569
  clearTimeout(timeoutId);
481
570
  };
482
- }, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers]);
571
+ }, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers, visibleWarmupRequirements]);
483
572
 
484
573
  useQuoteStreaming(streamTargets);
485
574
 
@@ -555,6 +644,30 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
555
644
  : [],
556
645
  }), [cashDrawerExpanded, setCashDrawerExpanded, showCashDrawer, summaryFooterInfo]);
557
646
 
647
+ const quickAddCollectionKind = useMemo<QuickAddCollectionKind | null>(() => {
648
+ if (!activeCollectionId) return null;
649
+ const collectionType = getCollectionTypeFromConfig(config, activeCollectionId);
650
+ if (collectionType === "watchlist") return "watchlist";
651
+ if (collectionType === "portfolio" && currentPortfolio && isManualPortfolio(currentPortfolio)) {
652
+ return "portfolio";
653
+ }
654
+ return null;
655
+ }, [activeCollectionId, config, currentPortfolio]);
656
+ const showQuickAdd = !!(activeCollectionId && activeCollectionEntry && quickAddCollectionKind);
657
+ const quickAddHeight = showQuickAdd ? 1 : 0;
658
+ const tableHeight = Math.max(1, height - headerHeight - drawerHeight - quickAddHeight);
659
+ const quickAddRow = activeCollectionId && activeCollectionEntry && quickAddCollectionKind ? (
660
+ <QuickAddTickerInput
661
+ collectionId={activeCollectionId}
662
+ collectionKind={quickAddCollectionKind}
663
+ collectionName={activeCollectionEntry.name}
664
+ focused={focused}
665
+ width={width}
666
+ onAdded={handleTickerAdded}
667
+ onFocusChange={setQuickAddFocused}
668
+ />
669
+ ) : null;
670
+
558
671
  return (
559
672
  <Box flexDirection="column" width={width} height={height}>
560
673
  <Box flexDirection="column" height={headerHeight}>
@@ -575,7 +688,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
575
688
 
576
689
  <PortfolioTickerTable
577
690
  columns={columns}
578
- focused={focused}
691
+ focused={focused && !quickAddFocused}
579
692
  sortColumnId={activeSort.columnId}
580
693
  sortDirection={activeSort.direction}
581
694
  onHeaderClick={handleHeaderClick}
@@ -590,8 +703,11 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
590
703
  visibleRangeBuffer={3}
591
704
  resetScrollKey={activeCollectionId}
592
705
  onRowActivate={handleRowActivate}
706
+ rootHeight={tableHeight}
593
707
  />
594
708
 
709
+ {quickAddRow}
710
+
595
711
  {showCashDrawer && accountState && (
596
712
  <Box height={drawerHeight} paddingX={1}>
597
713
  <PortfolioCashMarginDrawer