gloomberb 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.ts +48 -0
  4. package/src/api-client/types.ts +45 -0
  5. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  6. package/src/app/runtime/desktop-deeplink.ts +496 -0
  7. package/src/app.tsx +34 -0
  8. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  9. package/src/components/chart/bar-chart-renderer.ts +111 -55
  10. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  11. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  12. package/src/components/chart/stock-chart/footer.ts +68 -13
  13. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  14. package/src/components/command-bar/pane-templates/items.ts +11 -3
  15. package/src/components/command-bar/routes/root/results.ts +1 -1
  16. package/src/components/command-bar/surface/index.test.tsx +52 -0
  17. package/src/components/command-bar/workflow/ops.ts +81 -20
  18. package/src/components/index.ts +1 -0
  19. package/src/components/toggle-list.tsx +29 -73
  20. package/src/components/ui/button.tsx +4 -1
  21. package/src/components/ui/checkbox.tsx +76 -0
  22. package/src/components/ui/choice-dialog.tsx +15 -3
  23. package/src/components/ui/frame.tsx +1 -1
  24. package/src/components/ui/index.ts +2 -0
  25. package/src/components/ui/message-composer.tsx +4 -0
  26. package/src/components/ui/native-select.tsx +4 -2
  27. package/src/market-data/coordinator/events.test.ts +49 -0
  28. package/src/market-data/coordinator/events.ts +25 -4
  29. package/src/market-data/coordinator/index.test.ts +223 -0
  30. package/src/market-data/coordinator/index.ts +40 -3
  31. package/src/market-data/coordinator/quotes.ts +13 -5
  32. package/src/market-data/hooks.tsx +17 -13
  33. package/src/market-data/market/freshness.ts +58 -1
  34. package/src/market-data/quotes/freshness.test.ts +49 -1
  35. package/src/market-data/quotes/freshness.ts +8 -0
  36. package/src/market-data/quotes/resolution.test.ts +185 -0
  37. package/src/market-data/quotes/resolution.ts +94 -8
  38. package/src/market-data/request-types.test.ts +61 -1
  39. package/src/market-data/request-types.ts +42 -3
  40. package/src/pane-settings.test.ts +1 -0
  41. package/src/plugins/builtin/account-management/footer.ts +2 -5
  42. package/src/plugins/builtin/account-management/form-components.tsx +260 -24
  43. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  44. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  45. package/src/plugins/builtin/account-management/model.ts +263 -3
  46. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  47. package/src/plugins/builtin/chat/chat.test.tsx +125 -0
  48. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  49. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  50. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  51. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  52. package/src/plugins/builtin/chat/content/index.tsx +154 -16
  53. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  54. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  55. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  56. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  58. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  59. package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
  60. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  61. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  62. package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
  63. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  64. package/src/plugins/builtin/chat/test-harness.tsx +21 -11
  65. package/src/plugins/builtin/cloud/plugin.tsx +7 -0
  66. package/src/plugins/builtin/correlation/index.tsx +4 -1
  67. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  68. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  69. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  70. package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
  71. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  72. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  73. package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
  74. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
  75. package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
  76. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
  77. package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
  78. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
  79. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  80. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  81. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  82. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  83. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  84. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  85. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  86. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  87. package/src/plugins/registry/context.ts +15 -0
  88. package/src/plugins/registry/index.ts +31 -0
  89. package/src/renderers/electrobun/bun/index.ts +42 -0
  90. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  91. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  92. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  93. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  94. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  95. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  96. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  97. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  98. package/src/renderers/electrobun/view/main.tsx +3 -0
  99. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  100. package/src/sources/provider-router/batches.ts +11 -3
  101. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  102. package/src/sources/provider-router/financial-routes.ts +55 -5
  103. package/src/sources/provider-router/financials.test.ts +49 -0
  104. package/src/sources/provider-router/financials.ts +40 -3
  105. package/src/sources/provider-router/index.test.ts +147 -0
  106. package/src/sources/provider-router/primary.ts +17 -3
  107. package/src/sources/provider-router/streaming.ts +6 -1
  108. package/src/sources/sec-edgar.test.ts +42 -0
  109. package/src/sources/sec-edgar.ts +1 -2
  110. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  111. package/src/sources/yahoo-finance/mappers.ts +2 -1
  112. package/src/sources/yahoo-finance/requests.ts +2 -1
  113. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  114. package/src/sources/yahoo-finance.ts +6 -2
  115. package/src/state/hooks/quote-streaming.test.ts +14 -1
  116. package/src/state/hooks/quote-streaming.ts +5 -4
  117. package/src/sync/controller.ts +296 -0
  118. package/src/sync/core-contributors.test.ts +251 -0
  119. package/src/sync/core-contributors.ts +400 -0
  120. package/src/sync/profile-analytics.ts +39 -0
  121. package/src/sync/react.ts +53 -0
  122. package/src/sync/types.ts +77 -0
  123. package/src/types/config.ts +1 -0
  124. package/src/types/desktop-deeplink.ts +7 -0
  125. package/src/types/plugin.ts +3 -0
  126. package/src/ui/host.tsx +14 -1
  127. package/src/ui/index.tsx +1 -0
  128. package/src/utils/exchanges.test.ts +11 -1
  129. package/src/utils/exchanges.ts +5 -1
  130. package/src/version.ts +1 -1
@@ -279,6 +279,7 @@ function PortfolioHarness({
279
279
  stateMutator,
280
280
  runtime = createTestPluginRuntime(),
281
281
  paneHeight = 24,
282
+ paneFocused = true,
282
283
  }: {
283
284
  config: AppConfig;
284
285
  collectionId: string;
@@ -290,6 +291,7 @@ function PortfolioHarness({
290
291
  stateMutator?: (state: ReturnType<typeof createInitialState>) => void;
291
292
  runtime?: PluginRuntimeAccess;
292
293
  paneHeight?: number;
294
+ paneFocused?: boolean;
293
295
  }) {
294
296
  const initialState = createPortfolioState(config, collectionId, expanded, {
295
297
  ticker,
@@ -309,7 +311,7 @@ function PortfolioHarness({
309
311
  <PortfolioPane
310
312
  paneId={TEST_PANE_ID}
311
313
  paneType="portfolio-list"
312
- focused
314
+ focused={paneFocused}
313
315
  width={100}
314
316
  height={paneHeight}
315
317
  />
@@ -323,11 +325,162 @@ async function flushFrame() {
323
325
  await act(async () => {
324
326
  await Promise.resolve();
325
327
  await testSetup!.renderOnce();
328
+ await new Promise((resolve) => setTimeout(resolve, 0));
326
329
  await Promise.resolve();
327
330
  await testSetup!.renderOnce();
328
331
  });
329
332
  }
330
333
 
334
+ function makeSortWarmupQuote(symbol: string): Quote {
335
+ return makeQuote({
336
+ symbol,
337
+ price: symbol === "SIVE" ? 46.7 : 100,
338
+ change: symbol === "SIVE" ? -9.6 : 0,
339
+ changePercent: symbol === "SIVE" ? -17.05 : 0,
340
+ previousClose: symbol === "SIVE" ? 56.3 : 100,
341
+ listingExchangeName: "NASDAQ",
342
+ });
343
+ }
344
+
345
+ function makeSortWarmupBrokerTicker(portfolioId: string, symbol: string, index: number): TickerRecord {
346
+ return makeTicker({
347
+ ticker: symbol,
348
+ name: symbol,
349
+ portfolios: [portfolioId],
350
+ positions: [{
351
+ portfolio: portfolioId,
352
+ shares: 10,
353
+ avgCost: 100,
354
+ currency: "USD",
355
+ broker: "ibkr",
356
+ brokerInstanceId: "ibkr-live",
357
+ brokerAccountId: "DU12345",
358
+ brokerContractId: 10_000 + index,
359
+ markPrice: 100,
360
+ }],
361
+ broker_contracts: [{
362
+ brokerId: "ibkr",
363
+ brokerInstanceId: "ibkr-live",
364
+ symbol,
365
+ localSymbol: symbol,
366
+ exchange: "SMART",
367
+ primaryExchange: "NASDAQ",
368
+ conId: 10_000 + index,
369
+ }],
370
+ });
371
+ }
372
+
373
+ async function renderHiddenChangePctSortWarmup(options: { staleCachedSiveSnapshot?: boolean } = {}) {
374
+ const portfolioId = "broker:ibkr-live:DU12345";
375
+ const config = createPortfolioConfigWithColumns(
376
+ portfolioId,
377
+ ["ticker", "price", "change_pct", "latency"],
378
+ [createBrokerInstance("gateway", "ibkr-live")],
379
+ );
380
+ const requestedSnapshots: string[] = [];
381
+ const provider: DataProvider = {
382
+ id: "test-provider",
383
+ name: "Test Provider",
384
+ async getTickerFinancials(symbol) {
385
+ requestedSnapshots.push(symbol);
386
+ return {
387
+ annualStatements: [],
388
+ quarterlyStatements: [],
389
+ priceHistory: [],
390
+ quote: makeSortWarmupQuote(symbol),
391
+ };
392
+ },
393
+ async getQuote(symbol) {
394
+ return makeSortWarmupQuote(symbol);
395
+ },
396
+ async getExchangeRate() {
397
+ return 1;
398
+ },
399
+ async search() {
400
+ return [];
401
+ },
402
+ async getArticleSummary() {
403
+ return null;
404
+ },
405
+ async getPriceHistory() {
406
+ return [];
407
+ },
408
+ subscribeQuotes() {
409
+ return () => {};
410
+ },
411
+ };
412
+ sharedCoordinator = new MarketDataCoordinator(provider);
413
+ setSharedMarketDataCoordinator(sharedCoordinator);
414
+
415
+ const tickers = Array.from({ length: 29 }, (_, index) => makeSortWarmupBrokerTicker(portfolioId, `T${String(index).padStart(2, "0")}`, index));
416
+ const sive = makeSortWarmupBrokerTicker(portfolioId, "SIVE", 29);
417
+ if (options.staleCachedSiveSnapshot) {
418
+ const siveInstrument = instrumentFromTicker(sive, "SIVE", { portfolioId });
419
+ if (!siveInstrument) throw new Error("expected SIVE instrument");
420
+ sharedCoordinator.primeCachedFinancials([{
421
+ instrument: siveInstrument,
422
+ financials: {
423
+ annualStatements: [],
424
+ quarterlyStatements: [],
425
+ priceHistory: [],
426
+ quote: makeQuote({
427
+ symbol: "SIVE",
428
+ price: 56.3,
429
+ change: 56.3,
430
+ changePercent: 100,
431
+ previousClose: 0,
432
+ listingExchangeName: "NASDAQ",
433
+ lastUpdated: Date.now() - 24 * 60 * 60_000,
434
+ }),
435
+ },
436
+ }]);
437
+ }
438
+
439
+ testSetup = await testRender(
440
+ <PortfolioHarness
441
+ config={config}
442
+ collectionId={portfolioId}
443
+ stateMutator={(state) => {
444
+ state.tickers = new Map([...tickers, sive].map((entry) => [entry.metadata.ticker, entry]));
445
+ state.financials = new Map(tickers.map((entry, index) => [
446
+ entry.metadata.ticker,
447
+ {
448
+ annualStatements: [],
449
+ quarterlyStatements: [],
450
+ priceHistory: [],
451
+ quote: makeQuote({
452
+ symbol: entry.metadata.ticker,
453
+ price: 100 + index,
454
+ change: index,
455
+ changePercent: index,
456
+ listingExchangeName: "NASDAQ",
457
+ }),
458
+ },
459
+ ]));
460
+ state.paneState[TEST_PANE_ID] = {
461
+ collectionId: portfolioId,
462
+ cursorSymbol: "T00",
463
+ cashDrawerExpanded: false,
464
+ collectionSorts: {
465
+ [portfolioId]: { columnId: "change_pct", direction: "asc" },
466
+ },
467
+ };
468
+ }}
469
+ paneHeight={12}
470
+ />,
471
+ { width: 100, height: 12 },
472
+ );
473
+
474
+ await flushFrame();
475
+ const beforeFrame = testSetup.captureCharFrame();
476
+ await act(async () => {
477
+ await new Promise((resolve) => setTimeout(resolve, 400));
478
+ });
479
+ await flushFrame();
480
+
481
+ return { beforeFrame, frame: testSetup.captureCharFrame(), requestedSnapshots };
482
+ }
483
+
331
484
  afterEach(async () => {
332
485
  if (testSetup) {
333
486
  testSetup.renderer.destroy();
@@ -690,6 +843,13 @@ describe("PortfolioListPane cash and margin UI", () => {
690
843
  test("renders portfolio grid from portfolio table values and opens the selected ticker", async () => {
691
844
  const portfolioId = "broker:ibkr-flex:DU12345";
692
845
  const config = createPortfolioConfig(portfolioId, [createBrokerInstance("flex")]);
846
+ const instance = config.layout.instances.find((entry) => entry.instanceId === TEST_PANE_ID);
847
+ if (instance) {
848
+ instance.settings = {
849
+ ...(instance.settings ?? {}),
850
+ viewMode: "grid",
851
+ };
852
+ }
693
853
  const pinned: Array<{ symbol: string; options: { floating?: boolean; paneType?: string } | undefined }> = [];
694
854
  const runtime = createTestPluginRuntime({
695
855
  pinTicker: (symbol, options) => {
@@ -702,12 +862,6 @@ describe("PortfolioListPane cash and margin UI", () => {
702
862
  config={config}
703
863
  collectionId={portfolioId}
704
864
  runtime={runtime}
705
- stateMutator={(state) => {
706
- state.paneState[TEST_PANE_ID] = {
707
- ...state.paneState[TEST_PANE_ID],
708
- viewMode: "grid",
709
- };
710
- }}
711
865
  />,
712
866
  { width: 100, height: 12 },
713
867
  );
@@ -715,7 +869,6 @@ describe("PortfolioListPane cash and margin UI", () => {
715
869
  await flushFrame();
716
870
 
717
871
  const frame = testSetup.captureCharFrame();
718
- expect(frame).toContain("Grid");
719
872
  expect(frame).toContain("AAPL");
720
873
  expect(frame).toContain("1.3k");
721
874
  expect(frame).toContain("+4.17%");
@@ -728,6 +881,32 @@ describe("PortfolioListPane cash and margin UI", () => {
728
881
  expect(pinned).toEqual([{ symbol: "AAPL", options: { floating: true, paneType: TICKER_RESEARCH_PANE_ID } }]);
729
882
  });
730
883
 
884
+ test("keeps watchlists in table view when grid is saved on the pane", async () => {
885
+ const config = createManualCollectionConfig("watchlist");
886
+ const instance = config.layout.instances.find((entry) => entry.instanceId === TEST_PANE_ID);
887
+ if (instance) {
888
+ instance.settings = {
889
+ ...(instance.settings ?? {}),
890
+ viewMode: "grid",
891
+ };
892
+ }
893
+
894
+ testSetup = await testRender(
895
+ <PortfolioHarness
896
+ config={config}
897
+ collectionId="watchlist"
898
+ ticker={makeTicker({ portfolios: [], watchlists: ["watchlist"], positions: [] })}
899
+ />,
900
+ { width: 100, height: 12 },
901
+ );
902
+
903
+ await flushFrame();
904
+
905
+ const frame = testSetup.captureCharFrame();
906
+ expect(frame).toContain("TICKER");
907
+ expect(frame).toContain("AAPL");
908
+ });
909
+
731
910
  test("keeps option avg cost on premium scale and multiplies quoted value by contract size", async () => {
732
911
  const portfolioId = "broker:ibkr-flex:DU12345";
733
912
  const optionTicker = "SPY 260619C00500000";
@@ -965,6 +1144,100 @@ describe("PortfolioListPane cash and margin UI", () => {
965
1144
  expect(frame).toContain("22.0");
966
1145
  });
967
1146
 
1147
+ test("force-refreshes old visible quote data even when the pane is not focused", async () => {
1148
+ const config = createPortfolioConfigWithColumns(
1149
+ "broker:ibkr-flex:DU12345",
1150
+ ["ticker", "price", "day_pnl"],
1151
+ [createBrokerInstance("flex")],
1152
+ );
1153
+ const oldQuote = makeQuote({
1154
+ price: 120,
1155
+ change: -5,
1156
+ changePercent: -4,
1157
+ currency: "EUR",
1158
+ listingExchangeName: "FWB2",
1159
+ marketState: "REGULAR",
1160
+ lastUpdated: Date.now() - 5 * 60 * 1000,
1161
+ });
1162
+ const batchOptions: Array<{ forceRefresh?: boolean } | undefined> = [];
1163
+ const provider: DataProvider = {
1164
+ id: "test-provider",
1165
+ name: "Test Provider",
1166
+ async getTickerFinancials() {
1167
+ return {
1168
+ annualStatements: [],
1169
+ quarterlyStatements: [],
1170
+ priceHistory: [],
1171
+ quote: oldQuote,
1172
+ };
1173
+ },
1174
+ async getQuotesBatch(targets, options) {
1175
+ batchOptions.push(options);
1176
+ return targets.map((target) => ({
1177
+ target,
1178
+ quote: makeQuote({
1179
+ symbol: target.symbol,
1180
+ price: 126,
1181
+ change: 1,
1182
+ changePercent: 0.8,
1183
+ currency: "EUR",
1184
+ listingExchangeName: "FWB2",
1185
+ marketState: "REGULAR",
1186
+ }),
1187
+ }));
1188
+ },
1189
+ async getQuote(symbol) {
1190
+ return makeQuote({ symbol, price: 126 });
1191
+ },
1192
+ async getExchangeRate() {
1193
+ return 1;
1194
+ },
1195
+ async search() {
1196
+ return [];
1197
+ },
1198
+ async getArticleSummary() {
1199
+ return null;
1200
+ },
1201
+ async getPriceHistory() {
1202
+ return [];
1203
+ },
1204
+ subscribeQuotes() {
1205
+ return () => {};
1206
+ },
1207
+ };
1208
+ sharedCoordinator = new MarketDataCoordinator(provider);
1209
+ setSharedMarketDataCoordinator(sharedCoordinator);
1210
+
1211
+ testSetup = await testRender(
1212
+ <PortfolioHarness
1213
+ config={config}
1214
+ collectionId="broker:ibkr-flex:DU12345"
1215
+ ticker={makeTicker({ exchange: "FWB2", currency: "EUR" })}
1216
+ quote={oldQuote}
1217
+ paneFocused={false}
1218
+ stateMutator={(state) => {
1219
+ state.focusedPaneId = "portfolio-list:other";
1220
+ state.paneState[TEST_PANE_ID] = {
1221
+ ...(state.paneState[TEST_PANE_ID] ?? {}),
1222
+ collectionSorts: {
1223
+ "broker:ibkr-flex:DU12345": { columnId: "ticker", direction: "asc" },
1224
+ },
1225
+ };
1226
+ }}
1227
+ />,
1228
+ { width: 100, height: 12 },
1229
+ );
1230
+
1231
+ await flushFrame();
1232
+ await act(async () => {
1233
+ await Promise.resolve();
1234
+ });
1235
+ await flushFrame();
1236
+
1237
+ expect(batchOptions.some((options) => options?.forceRefresh === true)).toBe(true);
1238
+ expect(testSetup.captureCharFrame()).toContain("126");
1239
+ });
1240
+
968
1241
  test("shows cached market cap on reopen for broker-linked rows", async () => {
969
1242
  const dbPath = createTempDbPath("cached-reopen-market-cap");
970
1243
  const persistence = new AppPersistence(dbPath);
@@ -1305,4 +1578,137 @@ describe("PortfolioListPane cash and margin UI", () => {
1305
1578
  expect(frame).toContain("+5.41%");
1306
1579
  });
1307
1580
 
1581
+ test("streams portfolio rows with the active collection broker contract", async () => {
1582
+ const config = createPortfolioConfigWithColumns(
1583
+ "broker:ibkr-live:DU12345",
1584
+ ["ticker", "price", "change_pct", "latency"],
1585
+ [createBrokerInstance("gateway", "ibkr-flex"), createBrokerInstance("gateway", "ibkr-live")],
1586
+ );
1587
+ let subscribedTargets: Array<{ symbol: string; context?: { brokerInstanceId?: string; instrument?: unknown } }> = [];
1588
+ const provider: DataProvider = {
1589
+ id: "test-provider",
1590
+ name: "Test Provider",
1591
+ async getTickerFinancials(symbol) {
1592
+ return {
1593
+ annualStatements: [],
1594
+ quarterlyStatements: [],
1595
+ priceHistory: [],
1596
+ quote: makeQuote({ symbol }),
1597
+ };
1598
+ },
1599
+ async getQuote(symbol) {
1600
+ return makeQuote({ symbol });
1601
+ },
1602
+ async getExchangeRate() {
1603
+ return 1;
1604
+ },
1605
+ async search() {
1606
+ return [];
1607
+ },
1608
+ async getArticleSummary() {
1609
+ return null;
1610
+ },
1611
+ async getPriceHistory() {
1612
+ return [];
1613
+ },
1614
+ subscribeQuotes(targets) {
1615
+ subscribedTargets = targets;
1616
+ return () => {};
1617
+ },
1618
+ };
1619
+ sharedCoordinator = new MarketDataCoordinator(provider);
1620
+ setSharedMarketDataCoordinator(sharedCoordinator);
1621
+
1622
+ const flexContract = {
1623
+ brokerId: "ibkr",
1624
+ brokerInstanceId: "ibkr-flex",
1625
+ symbol: "VICR",
1626
+ localSymbol: "VICR",
1627
+ exchange: "NASDAQ",
1628
+ conId: 275759,
1629
+ };
1630
+ const liveContract = {
1631
+ brokerId: "ibkr",
1632
+ brokerInstanceId: "ibkr-live",
1633
+ symbol: "VICR",
1634
+ localSymbol: "VICR",
1635
+ exchange: "NASDAQ",
1636
+ primaryExchange: "NASDAQ",
1637
+ conId: 275759,
1638
+ };
1639
+
1640
+ testSetup = await testRender(
1641
+ <PortfolioHarness
1642
+ config={config}
1643
+ collectionId="broker:ibkr-live:DU12345"
1644
+ stateMutator={(state) => {
1645
+ state.tickers = new Map([["VICR", makeTicker({
1646
+ ticker: "VICR",
1647
+ name: "Vicor",
1648
+ portfolios: ["broker:ibkr-flex:DU12345", "broker:ibkr-live:DU12345"],
1649
+ positions: [
1650
+ {
1651
+ portfolio: "broker:ibkr-flex:DU12345",
1652
+ shares: 170,
1653
+ avgCost: 198,
1654
+ currency: "USD",
1655
+ broker: "ibkr",
1656
+ brokerInstanceId: "ibkr-flex",
1657
+ brokerAccountId: "DU12345",
1658
+ brokerContractId: 275759,
1659
+ },
1660
+ {
1661
+ portfolio: "broker:ibkr-live:DU12345",
1662
+ shares: 350,
1663
+ avgCost: 290,
1664
+ currency: "USD",
1665
+ broker: "ibkr",
1666
+ brokerInstanceId: "ibkr-live",
1667
+ brokerAccountId: "DU12345",
1668
+ brokerContractId: 275759,
1669
+ },
1670
+ ],
1671
+ broker_contracts: [flexContract, liveContract],
1672
+ })]]);
1673
+ state.financials = new Map();
1674
+ state.paneState[TEST_PANE_ID] = {
1675
+ collectionId: "broker:ibkr-live:DU12345",
1676
+ cursorSymbol: "VICR",
1677
+ cashDrawerExpanded: false,
1678
+ };
1679
+ }}
1680
+ />,
1681
+ { width: 100, height: 12 },
1682
+ );
1683
+
1684
+ await flushFrame();
1685
+ await act(async () => {
1686
+ await Promise.resolve();
1687
+ });
1688
+
1689
+ const vicrTarget = subscribedTargets.find((target) => target.symbol === "VICR");
1690
+ expect(vicrTarget?.context?.brokerInstanceId).toBe("ibkr-live");
1691
+ expect(vicrTarget?.context?.instrument).toEqual(liveContract);
1692
+ });
1693
+
1694
+ test("warms hidden quote-missing rows when sorting by change percent", async () => {
1695
+ const { beforeFrame, frame, requestedSnapshots } = await renderHiddenChangePctSortWarmup();
1696
+ expect(beforeFrame).not.toContain("SIVE");
1697
+ expect(requestedSnapshots).toContain("SIVE");
1698
+ expect(frame).toContain("SIVE");
1699
+ expect(frame).toContain("-17.05%");
1700
+ expect(frame).toContain("46.7");
1701
+ });
1702
+
1703
+ test("force-refreshes hidden stale cached snapshots when sorting by change percent", async () => {
1704
+ const { beforeFrame, frame, requestedSnapshots } = await renderHiddenChangePctSortWarmup({
1705
+ staleCachedSiveSnapshot: true,
1706
+ });
1707
+ expect(beforeFrame).not.toContain("SIVE");
1708
+ expect(requestedSnapshots).toContain("SIVE");
1709
+ expect(frame).toContain("SIVE");
1710
+ expect(frame).toContain("-17.05%");
1711
+ expect(frame).toContain("46.7");
1712
+ });
1713
+
1308
1714
  });
@@ -13,6 +13,7 @@ import {
13
13
  useAppSelector,
14
14
  usePaneCollection,
15
15
  usePaneInstance,
16
+ usePaneSettingValue,
16
17
  usePaneStateValue,
17
18
  type CollectionSortPreference,
18
19
  } from "../../../../state/app/context";
@@ -33,6 +34,7 @@ import {
33
34
  resolveActiveCollectionId,
34
35
  resolveScopedCollectionEntries,
35
36
  resolveVisibleColumns,
37
+ type PortfolioViewMode,
36
38
  } from "../settings";
37
39
  import { useQuoteFlashMap } from "../../../../components/quote-flash";
38
40
  import { PortfolioTickerTable } from "../table";
@@ -50,8 +52,6 @@ import {
50
52
  import { usePortfolioPaneStreaming } from "./streaming";
51
53
  import { usePortfolioSupplementalData } from "./supplemental";
52
54
 
53
- type PortfolioViewMode = "table" | "grid";
54
-
55
55
  export function PortfolioListPane({ focused, width, height }: PaneProps) {
56
56
  const { pinTicker } = usePluginTickerActions();
57
57
  const paneInstance = usePaneInstance();
@@ -68,7 +68,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
68
68
  const [committedCursorSymbol, setCommittedCursorSymbol] = usePaneStateValue<string | null>("cursorSymbol", null);
69
69
  const [collectionSorts, setCollectionSorts] = usePaneStateValue<Record<string, CollectionSortPreference>>("collectionSorts", {});
70
70
  const [cashDrawerExpanded, setCashDrawerExpanded] = usePaneStateValue<boolean>("cashDrawerExpanded", false);
71
- const [viewMode, setViewMode] = usePaneStateValue<PortfolioViewMode>("viewMode", "table");
71
+ const [, setViewMode] = usePaneSettingValue<PortfolioViewMode>("viewMode", "table");
72
72
 
73
73
  const [now, setNow] = useState(Date.now());
74
74
  const [streamWindow, setStreamWindow] = useState({ start: 0, end: 24 });
@@ -101,12 +101,16 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
101
101
  ? config.portfolios.find((portfolio) => portfolio.id === activeCollectionId) ?? null
102
102
  : null
103
103
  ), [activeCollectionId, config.portfolios, isPortfolioTab]);
104
+ const viewMode: PortfolioViewMode = isPortfolioTab ? paneSettings.viewMode : "table";
104
105
 
105
106
  const tickers = useMemo(
106
107
  () => getCollectionTickersFromConfig(config, tickersBySymbol, activeCollectionId),
107
108
  [activeCollectionId, config, tickersBySymbol],
108
109
  );
109
- const marketFinancialsMap = useTickerFinancialsMap(tickers);
110
+ const financialsInstrumentOptions = useMemo(() => ({
111
+ portfolioId: isPortfolioTab ? activeCollectionId : undefined,
112
+ }), [activeCollectionId, isPortfolioTab]);
113
+ const marketFinancialsMap = useTickerFinancialsMap(tickers, financialsInstrumentOptions);
110
114
  const financialsMap = useMemo(() => {
111
115
  const merged = new Map(cachedFinancials);
112
116
  for (const [symbol, financials] of marketFinancialsMap) {
@@ -178,8 +182,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
178
182
  ? (cashDrawerExpanded ? Math.min(6, Math.max(3, 2 + accountState.visibleCashBalances.length)) : 1)
179
183
  : 0;
180
184
  const showCollectionTabs = visibleCollections.length > 1;
181
- const viewTabsHeight = 1;
182
- const headerHeight = viewTabsHeight + (showCollectionTabs ? 1 : 0);
185
+ const headerHeight = showCollectionTabs ? 1 : 0;
183
186
  const drawerHeight = showCashDrawer
184
187
  ? Math.min(requestedDrawerHeight, Math.max(1, height - (headerHeight + 2)))
185
188
  : 0;
@@ -220,8 +223,9 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
220
223
  }, [pinTicker]);
221
224
 
222
225
  const toggleViewMode = useCallback(() => {
226
+ if (!isPortfolioTab) return;
223
227
  setViewMode((current) => current === "table" ? "grid" : "table");
224
- }, [setViewMode]);
228
+ }, [isPortfolioTab, setViewMode]);
225
229
 
226
230
  const handleRowActivate = useCallback((ticker: TickerRecord) => {
227
231
  flushCursorSymbol(ticker.metadata.ticker);
@@ -254,7 +258,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
254
258
  return true;
255
259
  }
256
260
 
257
- if (key === "s") {
261
+ if (key === "s" && isPortfolioTab) {
258
262
  event.preventDefault?.();
259
263
  event.stopPropagation?.();
260
264
  toggleViewMode();
@@ -265,6 +269,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
265
269
  }, [
266
270
  cashDrawerExpanded,
267
271
  focused,
272
+ isPortfolioTab,
268
273
  openTickerFloating,
269
274
  safeSelectedIdx,
270
275
  setCashDrawerExpanded,
@@ -301,16 +306,20 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
301
306
  const effectiveStreamWindow = useMemo(() => (
302
307
  viewMode === "grid"
303
308
  ? { start: 0, end: Math.min(sortedTickers.length, 96) }
304
- : streamWindow
305
- ), [sortedTickers.length, streamWindow, viewMode]);
309
+ : {
310
+ start: streamWindow.start,
311
+ end: Math.min(sortedTickers.length, Math.max(streamWindow.end, height + 4)),
312
+ }
313
+ ), [height, sortedTickers.length, streamWindow, viewMode]);
306
314
 
307
315
  usePortfolioPaneStreaming({
308
316
  appActive,
309
- focused,
317
+ activeCollectionId: isPortfolioTab ? activeCollectionId : undefined,
310
318
  sortedTickers,
311
319
  cursorSymbol,
312
320
  streamWindow: effectiveStreamWindow,
313
321
  isPortfolioTab,
322
+ activeSort,
314
323
  financialsMap,
315
324
  visibleWarmupRequirements,
316
325
  });
@@ -379,25 +388,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
379
388
 
380
389
  return (
381
390
  <Box flexDirection="column" width={width} height={height}>
382
- <Box flexDirection="column" height={headerHeight}>
383
- <Box flexDirection="row" height={1}>
384
- <Box flexShrink={1} overflow="hidden">
385
- <Tabs
386
- tabs={[
387
- { label: "Table", value: "table" },
388
- { label: "Grid", value: "grid" },
389
- ]}
390
- activeValue={viewMode}
391
- onSelect={(value) => setViewMode(value as PortfolioViewMode)}
392
- compact
393
- variant="bare"
394
- focused={focused && !quickAddFocused}
395
- keyboardNavigation={false}
396
- />
397
- </Box>
398
- </Box>
399
-
400
- {showCollectionTabs && (
391
+ {showCollectionTabs && (
392
+ <Box flexDirection="column" height={headerHeight}>
401
393
  <Box flexDirection="row" height={1}>
402
394
  <Box flexShrink={1} overflow="hidden">
403
395
  <Tabs
@@ -409,8 +401,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
409
401
  />
410
402
  </Box>
411
403
  </Box>
412
- )}
413
- </Box>
404
+ </Box>
405
+ )}
414
406
 
415
407
  {viewMode === "table" ? (
416
408
  <PortfolioTickerTable