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.
- package/README.md +9 -7
- package/package.json +14 -13
- package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
- package/src/app/global-shortcuts.test.tsx +54 -4
- package/src/app/global-shortcuts.ts +11 -5
- package/src/components/command-bar/action-runtime.ts +1 -0
- package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
- package/src/components/command-bar/layout-items/current-layout.ts +33 -0
- package/src/components/command-bar/list/view.tsx +3 -1
- package/src/components/command-bar/pane-templates/items.ts +4 -1
- package/src/components/command-bar/panel/body.tsx +1 -0
- package/src/components/command-bar/routes/root/results.ts +0 -30
- package/src/components/command-bar/routes/root/runtime.ts +2 -20
- package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
- package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
- package/src/components/command-bar/routing/launch-request.ts +14 -7
- package/src/components/command-bar/routing/list-state.ts +1 -3
- package/src/components/command-bar/surface/index.test.tsx +76 -4
- package/src/components/command-bar/surface/index.tsx +0 -2
- package/src/components/command-bar/surface/pane.test.tsx +1 -0
- package/src/components/command-bar/surface/test-harness.tsx +1 -0
- package/src/components/command-bar/view-model.ts +3 -3
- package/src/components/layout/shell/index.test.tsx +120 -2
- package/src/components/layout/shell/index.tsx +2 -0
- package/src/components/layout/shell/menu.ts +1 -7
- package/src/components/layout/shell/pane/actions.ts +8 -0
- package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
- package/src/components/layout/shell/shortcuts.ts +14 -6
- package/src/components/layout/shell/window-mode/index.ts +8 -3
- package/src/components/layout/window-edit/presentation.ts +4 -4
- package/src/components/onboarding/onboarding-steps.tsx +3 -2
- package/src/components/ticker/list-table-view.tsx +6 -0
- package/src/components/ui/remote-image.tsx +1 -14
- package/src/components/ui/tabs.tsx +95 -80
- package/src/components/ui/ui.test.tsx +54 -0
- package/src/core/state/app/types.ts +12 -4
- package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
- package/src/plugins/builtin/chat/content/index.tsx +5 -4
- package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
- package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
- package/src/plugins/builtin/help/components.tsx +1 -1
- package/src/plugins/builtin/help/index.test.tsx +105 -30
- package/src/plugins/builtin/help/index.tsx +324 -168
- package/src/plugins/builtin/help/shortcut-model.ts +5 -6
- package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
- package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
- package/src/plugins/builtin/substack/api/auth.ts +185 -0
- package/src/plugins/builtin/substack/api/cache.ts +78 -0
- package/src/plugins/builtin/substack/api/loaders.ts +146 -0
- package/src/plugins/builtin/substack/api/store.ts +186 -0
- package/src/plugins/builtin/substack/api/types.ts +40 -0
- package/src/plugins/builtin/substack/api.test.ts +298 -0
- package/src/plugins/builtin/substack/article-detail.tsx +359 -0
- package/src/plugins/builtin/substack/article-stack.tsx +129 -0
- package/src/plugins/builtin/substack/content.ts +478 -0
- package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
- package/src/plugins/builtin/substack/index.tsx +49 -0
- package/src/plugins/builtin/substack/login-view.tsx +126 -0
- package/src/plugins/builtin/substack/model.test.ts +155 -0
- package/src/plugins/builtin/substack/normalize.ts +343 -0
- package/src/plugins/builtin/substack/pane-footer.ts +71 -0
- package/src/plugins/builtin/substack/pane-state.ts +170 -0
- package/src/plugins/builtin/substack/pane.tsx +500 -0
- package/src/plugins/builtin/substack/table.ts +111 -0
- package/src/plugins/builtin/substack/types.ts +71 -0
- package/src/plugins/builtin/substack/utils.ts +131 -0
- package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
- package/src/plugins/catalog-ui.ts +2 -0
- package/src/plugins/pane-manager/layout-state.ts +11 -0
- package/src/plugins/pane-manager.ts +1 -1
- package/src/react/use-throttled-commit-value.ts +116 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
- package/src/renderers/electrobun/view/http-fetch.ts +10 -3
- package/src/state/app/context/index.test.tsx +16 -0
- package/src/tickers/search/index.ts +1 -0
- 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
|
-
|
|
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(
|