gloomberb 0.8.2 → 0.9.0

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 (85) hide show
  1. package/README.md +9 -7
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/app/global-shortcuts.test.tsx +54 -4
  5. package/src/app/global-shortcuts.ts +11 -5
  6. package/src/components/command-bar/action-runtime.ts +1 -0
  7. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  8. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  9. package/src/components/command-bar/list/view.tsx +3 -1
  10. package/src/components/command-bar/pane-templates/items.ts +4 -1
  11. package/src/components/command-bar/panel/body.tsx +1 -0
  12. package/src/components/command-bar/routes/root/results.ts +0 -30
  13. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  14. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  15. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  16. package/src/components/command-bar/routing/launch-request.ts +14 -7
  17. package/src/components/command-bar/routing/list-state.ts +1 -3
  18. package/src/components/command-bar/surface/index.test.tsx +76 -4
  19. package/src/components/command-bar/surface/index.tsx +0 -2
  20. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  21. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  22. package/src/components/command-bar/view-model.ts +3 -3
  23. package/src/components/layout/shell/index.test.tsx +120 -2
  24. package/src/components/layout/shell/index.tsx +2 -0
  25. package/src/components/layout/shell/menu.ts +1 -7
  26. package/src/components/layout/shell/pane/actions.ts +8 -0
  27. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  28. package/src/components/layout/shell/shortcuts.ts +14 -6
  29. package/src/components/layout/shell/window-mode/index.ts +8 -3
  30. package/src/components/layout/window-edit/presentation.ts +4 -4
  31. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  32. package/src/components/ticker/list-table-view.tsx +6 -0
  33. package/src/components/ui/remote-image.tsx +1 -14
  34. package/src/components/ui/tabs.tsx +95 -80
  35. package/src/components/ui/ui.test.tsx +54 -0
  36. package/src/core/state/app/types.ts +12 -4
  37. package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
  38. package/src/plugins/builtin/chat/content/index.tsx +5 -4
  39. package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
  40. package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
  41. package/src/plugins/builtin/help/components.tsx +1 -1
  42. package/src/plugins/builtin/help/index.test.tsx +105 -30
  43. package/src/plugins/builtin/help/index.tsx +324 -168
  44. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  45. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  46. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  47. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  48. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  49. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  50. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  51. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  52. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  53. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  54. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  55. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  56. package/src/plugins/builtin/substack/api/store.ts +186 -0
  57. package/src/plugins/builtin/substack/api/types.ts +40 -0
  58. package/src/plugins/builtin/substack/api.test.ts +298 -0
  59. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  60. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  61. package/src/plugins/builtin/substack/content.ts +478 -0
  62. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  63. package/src/plugins/builtin/substack/index.tsx +49 -0
  64. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  65. package/src/plugins/builtin/substack/model.test.ts +155 -0
  66. package/src/plugins/builtin/substack/normalize.ts +343 -0
  67. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  68. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  69. package/src/plugins/builtin/substack/pane.tsx +500 -0
  70. package/src/plugins/builtin/substack/table.ts +111 -0
  71. package/src/plugins/builtin/substack/types.ts +71 -0
  72. package/src/plugins/builtin/substack/utils.ts +131 -0
  73. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  74. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  75. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  76. package/src/plugins/catalog-ui.ts +2 -0
  77. package/src/plugins/pane-manager/layout-state.ts +11 -0
  78. package/src/plugins/pane-manager.ts +1 -1
  79. package/src/react/use-throttled-commit-value.ts +116 -0
  80. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  81. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  82. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  83. package/src/state/app/context/index.test.tsx +16 -0
  84. package/src/tickers/search/index.ts +1 -0
  85. package/src/version.ts +1 -1
@@ -1,5 +1,6 @@
1
1
  import type { ColumnConfig } from "../../../types/config";
2
- import type { TickerFinancials } from "../../../types/financials";
2
+ import type { AnalystResearchData, CorporateActionsData, TickerFinancials } from "../../../types/financials";
3
+ import type { EarningsEvent } from "../../../types/data-provider";
3
4
  import type { TickerRecord } from "../../../types/ticker";
4
5
  import { priceColor } from "../../../theme/colors";
5
6
  import { formatQuoteAgeWithSource, resolveQuoteAgeTimestamp } from "../../../market-data/quotes/time";
@@ -7,6 +8,7 @@ import { convertCurrency, formatCompact, formatNumber, formatPercentRaw } from "
7
8
  import {
8
9
  formatMarketCost,
9
10
  formatMarketPrice,
11
+ formatMarketPriceWithCurrency,
10
12
  formatMarketQuantity,
11
13
  formatSignedMarketPrice,
12
14
  type MarketFormatOptions,
@@ -25,6 +27,133 @@ export interface ColumnContext {
25
27
  baseCurrency: string;
26
28
  exchangeRates: Map<string, number>;
27
29
  now: number;
30
+ portfolioTotalMarketValue?: number;
31
+ supplementalVersion?: number;
32
+ analystResearch?: Map<string, AnalystResearchData | null>;
33
+ corporateActions?: Map<string, CorporateActionsData | null>;
34
+ earningsEvents?: Map<string, EarningsEvent | null>;
35
+ }
36
+
37
+ const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
38
+
39
+ function finiteNumber(value: unknown): value is number {
40
+ return typeof value === "number" && Number.isFinite(value);
41
+ }
42
+
43
+ function parseDateValue(value: Date | string | number | null | undefined): Date | null {
44
+ if (value == null) return null;
45
+ const date = value instanceof Date ? value : new Date(value);
46
+ return Number.isNaN(date.getTime()) ? null : date;
47
+ }
48
+
49
+ function formatShortDate(value: Date | string | number | null | undefined): string {
50
+ const date = parseDateValue(value);
51
+ if (!date) return "—";
52
+ return `${MONTH_NAMES[date.getUTCMonth()]} ${date.getUTCDate()}`;
53
+ }
54
+
55
+ function startOfUtcDay(date: Date): number {
56
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
57
+ }
58
+
59
+ function daysSince(value: Date | string | number | null | undefined, now: number): number | null {
60
+ const date = parseDateValue(value);
61
+ if (!date) return null;
62
+ return Math.max(0, Math.floor((startOfUtcDay(new Date(now)) - startOfUtcDay(date)) / 86_400_000));
63
+ }
64
+
65
+ function formatHeldDays(days: number | null): string {
66
+ if (days == null) return "—";
67
+ if (days < 365) return `${days}d`;
68
+ return `${formatNumber(days / 365, 1)}y`;
69
+ }
70
+
71
+ function activePositions(ticker: TickerRecord, activeTab: string | undefined): TickerRecord["metadata"]["positions"] {
72
+ return activeTab
73
+ ? ticker.metadata.positions.filter((position) => position.portfolio === activeTab)
74
+ : ticker.metadata.positions;
75
+ }
76
+
77
+ function earliestDateAcquired(ticker: TickerRecord, activeTab: string | undefined): Date | null {
78
+ return activePositions(ticker, activeTab)
79
+ .map((position) => parseDateValue(position.dateAcquired))
80
+ .filter((date): date is Date => date != null)
81
+ .sort((left, right) => left.getTime() - right.getTime())[0] ?? null;
82
+ }
83
+
84
+ function positionSideLabel(ticker: TickerRecord, activeTab: string | undefined): string | null {
85
+ const positions = activePositions(ticker, activeTab);
86
+ if (positions.length === 0) return null;
87
+ const shortCount = positions.filter((position) => position.side === "short").length;
88
+ if (shortCount === 0) return "LONG";
89
+ if (shortCount === positions.length) return "SHORT";
90
+ return "MIX";
91
+ }
92
+
93
+ function compactText(value: string | null | undefined): string {
94
+ const trimmed = value?.trim();
95
+ return trimmed ? trimmed : "—";
96
+ }
97
+
98
+ function targetValue(data: AnalystResearchData | null | undefined): number | null {
99
+ const target = data?.priceTarget;
100
+ return target?.average ?? target?.median ?? target?.high ?? target?.low ?? null;
101
+ }
102
+
103
+ function mapData<T>(map: Map<string, T | null> | undefined, symbol: string): { pending: boolean; data: T | null } {
104
+ if (!map) return { pending: false, data: null };
105
+ if (!map.has(symbol)) return { pending: true, data: null };
106
+ return { pending: false, data: map.get(symbol) ?? null };
107
+ }
108
+
109
+ function futureOrLatestDate<T>(
110
+ values: readonly T[],
111
+ getDate: (value: T) => string | Date | number | null | undefined,
112
+ now: number,
113
+ ): Date | null {
114
+ const dates = values
115
+ .map(getDate)
116
+ .map(parseDateValue)
117
+ .filter((date): date is Date => date != null)
118
+ .sort((left, right) => left.getTime() - right.getTime());
119
+ if (dates.length === 0) return null;
120
+ const today = startOfUtcDay(new Date(now));
121
+ return dates.find((date) => startOfUtcDay(date) >= today) ?? dates.at(-1) ?? null;
122
+ }
123
+
124
+ function nextEarningsDate(symbol: string, ctx: ColumnContext): { pending: boolean; date: Date | null } {
125
+ const event = mapData(ctx.earningsEvents, symbol);
126
+ if (event.pending) return { pending: true, date: null };
127
+ if (event.data) return { pending: false, date: event.data.earningsDate };
128
+
129
+ const actions = mapData(ctx.corporateActions, symbol);
130
+ if (actions.pending) return { pending: true, date: null };
131
+ return {
132
+ pending: false,
133
+ date: futureOrLatestDate(actions.data?.earnings ?? [], (earning) => earning.date, ctx.now),
134
+ };
135
+ }
136
+
137
+ function exDividendDate(symbol: string, ctx: ColumnContext): { pending: boolean; date: Date | null } {
138
+ const actions = mapData(ctx.corporateActions, symbol);
139
+ if (actions.pending) return { pending: true, date: null };
140
+ return {
141
+ pending: false,
142
+ date: futureOrLatestDate(actions.data?.dividends ?? [], (dividend) => dividend.exDate, ctx.now),
143
+ };
144
+ }
145
+
146
+ function getActiveMarketValue(
147
+ activeQuote: ActiveQuoteDisplay | null,
148
+ positionMetrics: ReturnType<typeof getPortfolioPositionMetrics>,
149
+ toBaseQuote: (value: number) => number,
150
+ toBasePosition: (value: number) => number,
151
+ ): number | null {
152
+ if (activeQuote && positionMetrics.totalPriceUnits !== 0) {
153
+ return toBaseQuote(Math.abs(positionMetrics.totalPriceUnits) * activeQuote.price);
154
+ }
155
+ const brokerFallbackMktValue = resolveBrokerFallbackMarketValue(positionMetrics);
156
+ return brokerFallbackMktValue == null ? null : toBasePosition(brokerFallbackMktValue);
28
157
  }
29
158
 
30
159
  export function resolvePortfolioPriceValue(
@@ -78,6 +207,27 @@ export function getColumnValue(
78
207
  : ticker.metadata.ticker;
79
208
  return { text: `${statusDot} ${displayName}` };
80
209
  }
210
+ case "name":
211
+ return { text: compactText(ticker.metadata.name || quote?.name) };
212
+ case "asset_type":
213
+ return { text: compactText(ticker.metadata.assetCategory) };
214
+ case "exchange":
215
+ return {
216
+ text: compactText(
217
+ quote?.listingExchangeName
218
+ || quote?.exchangeName
219
+ || quote?.routingExchangeName
220
+ || ticker.metadata.exchange,
221
+ ),
222
+ };
223
+ case "currency":
224
+ return { text: compactText((positionCurrency || quoteCurrency || ticker.metadata.currency || "").toUpperCase()) };
225
+ case "sector":
226
+ return { text: compactText(ticker.metadata.sector || financials?.profile?.sector) };
227
+ case "industry":
228
+ return { text: compactText(ticker.metadata.industry || financials?.profile?.industry) };
229
+ case "tags":
230
+ return { text: ticker.metadata.tags.length > 0 ? ticker.metadata.tags.join(",") : "—" };
81
231
  case "price":
82
232
  return resolvePortfolioPriceValue(activeQuote, brokerMarkPrice, formatOptions, col.width);
83
233
  case "change":
@@ -96,10 +246,33 @@ export function getColumnValue(
96
246
  ? formatMarketPrice(quote.ask - quote.bid, { ...formatOptions, maxWidth: col.width })
97
247
  : "—",
98
248
  };
249
+ case "spread_pct": {
250
+ if (!finiteNumber(quote?.bid) || !finiteNumber(quote?.ask)) return { text: "—" };
251
+ const midpoint = (quote.bid + quote.ask) / 2;
252
+ if (midpoint === 0) return { text: "—" };
253
+ return { text: formatPercentRaw(((quote.ask - quote.bid) / Math.abs(midpoint)) * 100) };
254
+ }
255
+ case "bid_ask_size": {
256
+ if (!finiteNumber(quote?.bidSize) && !finiteNumber(quote?.askSize)) return { text: "—" };
257
+ return { text: `${formatCompact(quote?.bidSize)}/${formatCompact(quote?.askSize)}` };
258
+ }
99
259
  case "change_pct":
100
260
  return activeQuote
101
261
  ? { text: formatPercentRaw(activeQuote.changePercent), color: priceColor(activeQuote.changePercent) }
102
262
  : { text: quote ? formatPercentRaw(quote.changePercent) : "—", color: quote ? priceColor(quote.changePercent) : undefined };
263
+ case "volume":
264
+ return { text: finiteNumber(quote?.volume) ? formatCompact(quote.volume) : "—" };
265
+ case "dollar_volume": {
266
+ if (!activeQuote || !finiteNumber(quote?.volume)) return { text: "—" };
267
+ return { text: formatCompact(toBaseQuote(activeQuote.price * quote.volume)) };
268
+ }
269
+ case "range_52w": {
270
+ if (!activeQuote || !finiteNumber(quote?.high52w) || !finiteNumber(quote?.low52w)) return { text: "—" };
271
+ const range = quote.high52w - quote.low52w;
272
+ if (range <= 0) return { text: "—" };
273
+ const position = ((activeQuote.price - quote.low52w) / range) * 100;
274
+ return { text: formatPercentRaw(Math.max(0, Math.min(100, position))) };
275
+ }
103
276
  case "market_cap":
104
277
  if (!quote?.marketCap) return { text: "—" };
105
278
  return { text: formatCompact(toBaseQuote(quote.marketCap)) };
@@ -121,6 +294,8 @@ export function getColumnValue(
121
294
  return { text: formatPercentRaw(changePercent), color: priceColor(changePercent) };
122
295
  }
123
296
  return { text: "—" };
297
+ case "side":
298
+ return { text: positionSideLabel(ticker, ctx.activeTab) ?? "—" };
124
299
  case "shares":
125
300
  return { text: totalShares !== 0 ? formatMarketQuantity(totalShares, { ...formatOptions, maxWidth: col.width }) : "—" };
126
301
  case "avg_cost":
@@ -137,6 +312,11 @@ export function getColumnValue(
137
312
  return { text: formatCompact(toBasePosition(brokerFallbackMktValue)) };
138
313
  }
139
314
  return { text: "—" };
315
+ case "weight": {
316
+ const marketValue = getActiveMarketValue(activeQuote, positionMetrics, toBaseQuote, toBasePosition);
317
+ if (marketValue == null || !ctx.portfolioTotalMarketValue) return { text: "—" };
318
+ return { text: formatPercentRaw((marketValue / ctx.portfolioTotalMarketValue) * 100) };
319
+ }
140
320
  case "day_pnl":
141
321
  if (activeQuote && totalPriceUnits !== 0) {
142
322
  const dayPnl = toBaseQuote(totalPriceUnits * activeQuote.change);
@@ -167,6 +347,53 @@ export function getColumnValue(
167
347
  return { text: formatPercentRaw(percent), color: priceColor(percent) };
168
348
  }
169
349
  return { text: "—" };
350
+ case "mark_delta":
351
+ if (!activeQuote || brokerMarkPrice == null || activeQuote.price === 0) return { text: "—" };
352
+ {
353
+ const percent = ((brokerMarkPrice - activeQuote.price) / Math.abs(activeQuote.price)) * 100;
354
+ return { text: formatPercentRaw(percent), color: priceColor(percent) };
355
+ }
356
+ case "acq_date": {
357
+ return { text: formatShortDate(earliestDateAcquired(ticker, ctx.activeTab)) };
358
+ }
359
+ case "held": {
360
+ return { text: formatHeldDays(daysSince(earliestDateAcquired(ticker, ctx.activeTab), ctx.now)) };
361
+ }
362
+ case "target": {
363
+ const analyst = mapData(ctx.analystResearch, ticker.metadata.ticker);
364
+ if (analyst.pending) return { text: "…" };
365
+ const value = targetValue(analyst.data);
366
+ const currency = analyst.data?.priceTarget?.currency ?? analyst.data?.currency ?? quoteCurrency;
367
+ return {
368
+ text: value == null
369
+ ? "—"
370
+ : formatMarketPriceWithCurrency(value, currency, { ...formatOptions, maxWidth: col.width }),
371
+ };
372
+ }
373
+ case "target_pct": {
374
+ const analyst = mapData(ctx.analystResearch, ticker.metadata.ticker);
375
+ if (analyst.pending) return { text: "…" };
376
+ const value = targetValue(analyst.data);
377
+ const current = analyst.data?.priceTarget?.current ?? activeQuote?.price;
378
+ if (value == null || !current) return { text: "—" };
379
+ const percent = ((value - current) / Math.abs(current)) * 100;
380
+ return { text: formatPercentRaw(percent), color: priceColor(percent) };
381
+ }
382
+ case "rating": {
383
+ const analyst = mapData(ctx.analystResearch, ticker.metadata.ticker);
384
+ if (analyst.pending) return { text: "…" };
385
+ return { text: analyst.data?.recommendationRating != null ? formatNumber(analyst.data.recommendationRating, 1) : "—" };
386
+ }
387
+ case "ex_div": {
388
+ const result = exDividendDate(ticker.metadata.ticker, ctx);
389
+ if (result.pending) return { text: "…" };
390
+ return { text: formatShortDate(result.date) };
391
+ }
392
+ case "next_earn": {
393
+ const result = nextEarningsDate(ticker.metadata.ticker, ctx);
394
+ if (result.pending) return { text: "…" };
395
+ return { text: formatShortDate(result.date) };
396
+ }
170
397
  case "latency":
171
398
  return { text: formatQuoteAgeWithSource(quote, ctx.now) };
172
399
  case PRICE_SPARKLINE_COLUMN_ID:
@@ -199,6 +426,24 @@ export function getSortValue(
199
426
  switch (col.id) {
200
427
  case "ticker":
201
428
  return ticker.metadata.ticker;
429
+ case "name":
430
+ return ticker.metadata.name || quote?.name || null;
431
+ case "asset_type":
432
+ return ticker.metadata.assetCategory ?? null;
433
+ case "exchange":
434
+ return quote?.listingExchangeName
435
+ ?? quote?.exchangeName
436
+ ?? quote?.routingExchangeName
437
+ ?? ticker.metadata.exchange
438
+ ?? null;
439
+ case "currency":
440
+ return (positionCurrency || quoteCurrency || ticker.metadata.currency || "").toUpperCase() || null;
441
+ case "sector":
442
+ return ticker.metadata.sector || financials?.profile?.sector || null;
443
+ case "industry":
444
+ return ticker.metadata.industry || financials?.profile?.industry || null;
445
+ case "tags":
446
+ return ticker.metadata.tags.join(",");
202
447
  case "price":
203
448
  if (activeQuote) return activeQuote.price;
204
449
  if (brokerMarkPrice != null) return brokerMarkPrice;
@@ -209,10 +454,30 @@ export function getSortValue(
209
454
  return quote?.ask ?? null;
210
455
  case "spread":
211
456
  return quote?.bid != null && quote?.ask != null ? quote.ask - quote.bid : null;
457
+ case "spread_pct": {
458
+ if (!finiteNumber(quote?.bid) || !finiteNumber(quote?.ask)) return null;
459
+ const midpoint = (quote.bid + quote.ask) / 2;
460
+ return midpoint !== 0 ? ((quote.ask - quote.bid) / Math.abs(midpoint)) * 100 : null;
461
+ }
462
+ case "bid_ask_size":
463
+ return finiteNumber(quote?.bidSize) || finiteNumber(quote?.askSize)
464
+ ? (quote?.bidSize ?? 0) + (quote?.askSize ?? 0)
465
+ : null;
212
466
  case "change":
213
467
  return activeQuote ? activeQuote.change : null;
214
468
  case "change_pct":
215
469
  return activeQuote?.changePercent ?? null;
470
+ case "volume":
471
+ return quote?.volume ?? null;
472
+ case "dollar_volume":
473
+ return activeQuote && finiteNumber(quote?.volume)
474
+ ? toBaseQuote(activeQuote.price * quote.volume)
475
+ : null;
476
+ case "range_52w": {
477
+ if (!activeQuote || !finiteNumber(quote?.high52w) || !finiteNumber(quote?.low52w)) return null;
478
+ const range = quote.high52w - quote.low52w;
479
+ return range > 0 ? ((activeQuote.price - quote.low52w) / range) * 100 : null;
480
+ }
216
481
  case "market_cap":
217
482
  return quote?.marketCap ? toBaseQuote(quote.marketCap) : null;
218
483
  case "pe":
@@ -229,6 +494,8 @@ export function getSortValue(
229
494
  return activeQuote?.changePercent ?? quote.postMarketChangePercent ?? 0;
230
495
  }
231
496
  return null;
497
+ case "side":
498
+ return positionSideLabel(ticker, ctx.activeTab);
232
499
  case "shares":
233
500
  return totalShares !== 0 ? totalShares : null;
234
501
  case "avg_cost":
@@ -243,6 +510,12 @@ export function getSortValue(
243
510
  return toBasePosition(brokerFallbackMktValue);
244
511
  }
245
512
  return null;
513
+ case "weight": {
514
+ const marketValue = getActiveMarketValue(activeQuote, positionMetrics, toBaseQuote, toBasePosition);
515
+ return marketValue != null && ctx.portfolioTotalMarketValue
516
+ ? (marketValue / ctx.portfolioTotalMarketValue) * 100
517
+ : null;
518
+ }
246
519
  case "day_pnl":
247
520
  if (activeQuote && totalPriceUnits !== 0) {
248
521
  return toBaseQuote(totalPriceUnits * activeQuote.change);
@@ -268,6 +541,36 @@ export function getSortValue(
268
541
  return costBasis !== 0 ? (pnl / costBasis) * 100 : null;
269
542
  }
270
543
  return null;
544
+ case "mark_delta":
545
+ return activeQuote && brokerMarkPrice != null && activeQuote.price !== 0
546
+ ? ((brokerMarkPrice - activeQuote.price) / Math.abs(activeQuote.price)) * 100
547
+ : null;
548
+ case "acq_date":
549
+ return earliestDateAcquired(ticker, ctx.activeTab)?.getTime() ?? null;
550
+ case "held":
551
+ return daysSince(earliestDateAcquired(ticker, ctx.activeTab), ctx.now);
552
+ case "target": {
553
+ const analyst = mapData(ctx.analystResearch, ticker.metadata.ticker);
554
+ return analyst.pending ? null : targetValue(analyst.data);
555
+ }
556
+ case "target_pct": {
557
+ const analyst = mapData(ctx.analystResearch, ticker.metadata.ticker);
558
+ const value = analyst.pending ? null : targetValue(analyst.data);
559
+ const current = analyst.data?.priceTarget?.current ?? activeQuote?.price;
560
+ return value != null && current ? ((value - current) / Math.abs(current)) * 100 : null;
561
+ }
562
+ case "rating": {
563
+ const analyst = mapData(ctx.analystResearch, ticker.metadata.ticker);
564
+ return analyst.pending ? null : analyst.data?.recommendationRating ?? null;
565
+ }
566
+ case "ex_div": {
567
+ const result = exDividendDate(ticker.metadata.ticker, ctx);
568
+ return result.pending ? null : result.date?.getTime() ?? null;
569
+ }
570
+ case "next_earn": {
571
+ const result = nextEarningsDate(ticker.metadata.ticker, ctx);
572
+ return result.pending ? null : result.date?.getTime() ?? null;
573
+ }
271
574
  case "latency":
272
575
  return quote ? ctx.now - (resolveQuoteAgeTimestamp(quote, ctx.now) ?? ctx.now) : null;
273
576
  case PRICE_SPARKLINE_COLUMN_ID: {
@@ -239,4 +239,97 @@ describe("portfolio-metrics", () => {
239
239
  text: "10s",
240
240
  });
241
241
  });
242
+
243
+ test("formats derived quote and position columns", () => {
244
+ const ticker = createTicker({
245
+ sector: "Technology",
246
+ industry: "Consumer Electronics",
247
+ assetCategory: "STK",
248
+ tags: ["core", "mega"],
249
+ positions: [{
250
+ portfolio: "main",
251
+ shares: 10,
252
+ avgCost: 100,
253
+ broker: "manual",
254
+ currency: "USD",
255
+ dateAcquired: "2024-01-01",
256
+ markPrice: 121.2,
257
+ }],
258
+ });
259
+ const financials = createFinancials({
260
+ quote: {
261
+ symbol: "AAPL",
262
+ price: 120,
263
+ currency: "USD",
264
+ change: 5,
265
+ changePercent: 4.35,
266
+ previousClose: 115,
267
+ bid: 119.5,
268
+ ask: 120.5,
269
+ bidSize: 100,
270
+ askSize: 150,
271
+ high52w: 160,
272
+ low52w: 80,
273
+ volume: 12_500_000,
274
+ },
275
+ });
276
+ const context: ColumnContext = {
277
+ ...defaultColumnContext,
278
+ portfolioTotalMarketValue: 2_400,
279
+ now: Date.UTC(2026, 0, 1),
280
+ };
281
+
282
+ expect(getColumnValue({ id: "weight", label: "WEIGHT", width: 8, align: "right" }, ticker, financials, context).text).toBe("+50.00%");
283
+ expect(getSortValue({ id: "weight", label: "WEIGHT", width: 8, align: "right" }, ticker, financials, context)).toBe(50);
284
+ expect(getColumnValue({ id: "range_52w", label: "52W%", width: 7, align: "right" }, ticker, financials, context).text).toBe("+50.00%");
285
+ expect(getColumnValue({ id: "dollar_volume", label: "$VOL", width: 9, align: "right" }, ticker, financials, context).text).toBe("1.5B");
286
+ expect(getColumnValue({ id: "spread_pct", label: "SPR%", width: 7, align: "right" }, ticker, financials, context).text).toBe("+0.83%");
287
+ expect(getColumnValue({ id: "bid_ask_size", label: "B/A SZ", width: 9, align: "right" }, ticker, financials, context).text).toBe("100/150");
288
+ expect(getColumnValue({ id: "mark_delta", label: "MARK%", width: 8, align: "right" }, ticker, financials, context).text).toBe("+1.00%");
289
+ expect(getColumnValue({ id: "held", label: "HELD", width: 6, align: "right" }, ticker, financials, context).text).toBe("2.0y");
290
+ expect(getColumnValue({ id: "tags", label: "TAGS", width: 14, align: "left" }, ticker, financials, context).text).toBe("core,mega");
291
+ });
292
+
293
+ test("formats supplemental analyst and corporate action columns", () => {
294
+ const ticker = createTicker();
295
+ const financials = createFinancials();
296
+ const context: ColumnContext = {
297
+ ...defaultColumnContext,
298
+ analystResearch: new Map([["AAPL", {
299
+ symbol: "AAPL",
300
+ currency: "USD",
301
+ priceTarget: { average: 150, current: 120, currency: "USD" },
302
+ recommendationRating: 8.4,
303
+ recommendations: [],
304
+ ratings: [],
305
+ earningsEstimates: [],
306
+ revenueEstimates: [],
307
+ }]]),
308
+ corporateActions: new Map([["AAPL", {
309
+ symbol: "AAPL",
310
+ dividends: [{ exDate: "2026-02-15", amount: 0.25 }],
311
+ splits: [],
312
+ earnings: [{ date: "2026-01-30", epsEstimate: 1.2 }],
313
+ }]]),
314
+ earningsEvents: new Map([["AAPL", {
315
+ symbol: "AAPL",
316
+ name: "Apple Inc.",
317
+ earningsDate: new Date(Date.UTC(2026, 0, 29)),
318
+ earningsCallDate: null,
319
+ epsEstimate: 1.2,
320
+ epsActual: null,
321
+ revenueEstimate: null,
322
+ revenueActual: null,
323
+ surprise: null,
324
+ timing: "AMC",
325
+ }]]),
326
+ };
327
+
328
+ expect(getColumnValue({ id: "target", label: "TARGET", width: 10, align: "right" }, ticker, financials, context).text).toBe("$150");
329
+ expect(getColumnValue({ id: "target_pct", label: "TARGET%", width: 8, align: "right" }, ticker, financials, context).text).toBe("+25.00%");
330
+ expect(getColumnValue({ id: "rating", label: "RATING", width: 7, align: "right" }, ticker, financials, context).text).toBe("8.4");
331
+ expect(getColumnValue({ id: "ex_div", label: "EX-DIV", width: 7, align: "right" }, ticker, financials, context).text).toBe("Feb 15");
332
+ expect(getColumnValue({ id: "next_earn", label: "ERN", width: 7, align: "right" }, ticker, financials, context).text).toBe("Jan 29");
333
+ expect(getSortValue({ id: "target_pct", label: "TARGET%", width: 8, align: "right" }, ticker, financials, context)).toBe(25);
334
+ });
242
335
  });
@@ -15,16 +15,19 @@ export const VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT = 3;
15
15
 
16
16
  const STREAM_OVERSCAN_ROWS = 6;
17
17
  const FUNDAMENTAL_COLUMN_IDS = new Set(["pe", "forward_pe", "dividend_yield"]);
18
+ const PROFILE_COLUMN_IDS = new Set(["sector", "industry"]);
18
19
  const sortValueCache = createRowValueCache<string, ReturnType<typeof getSortValue>>(5000);
19
20
 
20
21
  export interface VisibleWarmupRequirements {
21
22
  fundamentals: boolean;
23
+ profile: boolean;
22
24
  priceHistory: boolean;
23
25
  }
24
26
 
25
27
  export function resolveVisibleWarmupRequirements(columns: ColumnConfig[]): VisibleWarmupRequirements {
26
28
  return {
27
29
  fundamentals: columns.some((column) => FUNDAMENTAL_COLUMN_IDS.has(column.id)),
30
+ profile: columns.some((column) => PROFILE_COLUMN_IDS.has(column.id)),
28
31
  priceHistory: columns.some((column) => column.id === PRICE_SPARKLINE_COLUMN_ID),
29
32
  };
30
33
  }
@@ -45,6 +48,7 @@ export function needsVisibleSnapshotWarmup(
45
48
  if (ticker.metadata.assetCategory === "OPT") return false;
46
49
  if (!financials?.quote) return false;
47
50
  if (requirements.fundamentals && Object.keys(financials.fundamentals ?? {}).length === 0) return true;
51
+ if (requirements.profile && !financials.profile) return true;
48
52
  return requirements.priceHistory && financials.priceHistory.length === 0;
49
53
  }
50
54
 
@@ -142,8 +146,11 @@ export function sortTickers(
142
146
  sortColumn.id,
143
147
  columnContext.activeTab ?? "",
144
148
  columnContext.baseCurrency,
149
+ columnContext.portfolioTotalMarketValue ?? 0,
150
+ columnContext.supplementalVersion ?? 0,
145
151
  exchangeRatesVersion,
146
152
  sortColumn.id === "latency" ? columnContext.now : 0,
153
+ sortColumn.id === "held" ? columnContext.now : 0,
147
154
  ].join("|");
148
155
  const sortValues = new Map<string, ReturnType<typeof getSortValue>>();
149
156
  for (const ticker of tickers) {
@@ -20,7 +20,7 @@ import { selectEffectiveExchangeRates } from "../../../../utils/exchange-rate-ma
20
20
  import type { TickerRecord } from "../../../../types/ticker";
21
21
  import type { PaneProps } from "../../../../types/plugin";
22
22
  import { TICKER_RESEARCH_PANE_ID } from "../../../../types/config";
23
- import { resolveCollectionSortPreference, type ColumnContext } from "../metrics";
23
+ import { calculatePortfolioSummaryTotals, resolveCollectionSortPreference, type ColumnContext } from "../metrics";
24
24
  import {
25
25
  PortfolioCashMarginDrawer,
26
26
  shouldToggleCashMarginDrawer,
@@ -47,6 +47,7 @@ import {
47
47
  sortTickers,
48
48
  } from "./data";
49
49
  import { usePortfolioPaneStreaming } from "./streaming";
50
+ import { usePortfolioSupplementalData } from "./supplemental";
50
51
 
51
52
  export function PortfolioListPane({ focused, width, height }: PaneProps) {
52
53
  const { pinTicker } = usePluginTickerActions();
@@ -118,6 +119,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
118
119
  () => resolveVisibleColumns(paneSettings.columnIds, isPortfolioTab),
119
120
  [isPortfolioTab, paneSettings.columnIds],
120
121
  );
122
+ const supplementalData = usePortfolioSupplementalData(tickers, columns, appActive);
121
123
  const visibleWarmupRequirements = useMemo(
122
124
  () => resolveVisibleWarmupRequirements(columns),
123
125
  [columns],
@@ -129,13 +131,34 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
129
131
  );
130
132
  const fetchedExchangeRates = useFxRatesMap(trackedCurrencies);
131
133
  const effectiveExchangeRates = selectEffectiveExchangeRates(fetchedExchangeRates, cachedExchangeRates);
134
+ const portfolioSummaryTotals = useMemo(() => calculatePortfolioSummaryTotals(
135
+ tickers,
136
+ financialsMap,
137
+ config.baseCurrency,
138
+ effectiveExchangeRates,
139
+ isPortfolioTab,
140
+ activeCollectionId,
141
+ ), [activeCollectionId, config.baseCurrency, effectiveExchangeRates, financialsMap, isPortfolioTab, tickers]);
132
142
 
133
143
  const columnContext: ColumnContext = useMemo(() => ({
134
144
  activeTab: isPortfolioTab ? activeCollectionId : undefined,
135
145
  baseCurrency: config.baseCurrency,
136
146
  exchangeRates: effectiveExchangeRates,
137
147
  now,
138
- }), [activeCollectionId, config.baseCurrency, effectiveExchangeRates, isPortfolioTab, now]);
148
+ portfolioTotalMarketValue: portfolioSummaryTotals.totalMktValue,
149
+ supplementalVersion: supplementalData.version,
150
+ analystResearch: supplementalData.analystResearch,
151
+ corporateActions: supplementalData.corporateActions,
152
+ earningsEvents: supplementalData.earningsEvents,
153
+ }), [
154
+ activeCollectionId,
155
+ config.baseCurrency,
156
+ effectiveExchangeRates,
157
+ isPortfolioTab,
158
+ now,
159
+ portfolioSummaryTotals.totalMktValue,
160
+ supplementalData,
161
+ ]);
139
162
 
140
163
  const activeSort = resolveCollectionSortPreference(activeCollectionId, isPortfolioTab, collectionSorts);
141
164
  const sortedTickers = useMemo(