import { createBaseConverter } from "../../../../cli/base-converter"; import { withMarketData } from "../../../../cli/scoped-context"; import { countCollectionTickers, formatSignedCurrency, formatSignedPercentRaw, } from "../../../../cli/helpers"; import { cliStyles, colorBySign, renderSection, renderStats, renderTable, } from "../../../../utils/cli-output"; import { formatCompact } from "../../../../utils/format"; import { currencyMinorDigits, formatMarketCostWithCurrency, formatMarketPriceWithCurrency, formatMarketQuantity, quoteFormatOptions } from "../../../../market-data/market/format"; import { resolvePriceBasis } from "../../../../market-data/market/price-basis"; import { getPortfolioPositionMetrics, getPortfolioQuoteDisplay, resolvePortfolioMarketValue, resolvePortfolioPositionPnl } from "../position-metrics"; import { exchangeShortName, getActiveQuoteDisplay } from "../../../../market-data/market/status"; import type { AppConfig } from "../../../../types/config"; import type { CliCommandContext } from "../../../../types/plugin"; import type { MarketContext } from "../../../../cli/types"; import type { TickerRecord } from "../../../../types/ticker"; import { instrumentFromTicker } from "../../../../market-data/request-types"; import { toMarketDataContext } from "../../../../market-data/selectors"; /** Money prices pad to the currency's minor unit ($337.90, not $337.9), as the ticker view does. */ function priceFormatOptions(quote: Parameters[0] & { currency?: string }, assetCategory?: string) { const options = quoteFormatOptions(quote, assetCategory); return resolvePriceBasis(options.priceBasis, options.assetCategory) === "per-unit" ? { ...options, minimumFractionDigits: Math.min(2, currencyMinorDigits(quote?.currency)) } : options; } export function renderCollectionOverview(config: AppConfig, tickers: TickerRecord[]): string { const blocks: string[] = []; blocks.push(renderSection("Portfolios")); if (config.portfolios.length === 0) { blocks.push(cliStyles.muted("No portfolios configured.")); } else { blocks.push(renderTable( [ { header: "Portfolio" }, { header: "Currency" }, { header: "Tickers", align: "right" }, ], config.portfolios.map((portfolio) => [ portfolio.name, portfolio.currency, String(countCollectionTickers(tickers, "portfolios", portfolio.id)), ]), )); } blocks.push(""); blocks.push(renderSection("Watchlists")); if (config.watchlists.length === 0) { blocks.push(cliStyles.muted("No watchlists configured.")); } else { blocks.push(renderTable( [ { header: "Watchlist" }, { header: "Tickers", align: "right" }, ], config.watchlists.map((watchlist) => [ watchlist.name, String(countCollectionTickers(tickers, "watchlists", watchlist.id)), ]), )); } return blocks.join("\n"); } export async function showCollection(name: string, ctx: CliCommandContext) { await withMarketData(ctx, (context) => showCollectionWithMarketData(name, ctx, context)); } async function showCollectionWithMarketData( name: string, ctx: CliCommandContext, { config, store, dataProvider }: MarketContext, ) { const tickers = (await store.loadAllTickers()).sort((left, right) => left.metadata.ticker.localeCompare(right.metadata.ticker) ); const baseCurrency = config.baseCurrency; const toBase = createBaseConverter(dataProvider, baseCurrency); const normalized = name.trim().toLowerCase(); const matchedPortfolio = config.portfolios.find((portfolio) => portfolio.id.toLowerCase() === normalized || portfolio.name.toLowerCase() === normalized ); const matchedWatchlist = config.watchlists.find((watchlist) => watchlist.id.toLowerCase() === normalized || watchlist.name.toLowerCase() === normalized ); if (!matchedPortfolio && !matchedWatchlist) { ctx.fail( `Collection "${name}" was not found.`, `Available: ${[...config.portfolios.map((portfolio) => portfolio.name), ...config.watchlists.map((watchlist) => watchlist.name)].join(", ")}`, ); } const isPortfolio = !!matchedPortfolio; const id = matchedPortfolio?.id ?? matchedWatchlist!.id; const displayName = matchedPortfolio?.name ?? matchedWatchlist!.name; const currency = matchedPortfolio?.currency ?? baseCurrency; const structured = isPortfolio && ctx.cliOptions?.format != null && ctx.cliOptions.format !== "text"; const filtered = tickers.filter((ticker) => isPortfolio ? ticker.metadata.portfolios.includes(id) : ticker.metadata.watchlists.includes(id) ); if (filtered.length === 0 && !structured) { console.log(cliStyles.bold(displayName)); console.log(cliStyles.muted("No tickers in this collection.")); return; } const quotes = new Map>>(); await Promise.all( filtered.map(async (ticker) => { try { const instrument = isPortfolio ? instrumentFromTicker(ticker, ticker.metadata.ticker, { portfolioId: id }) : null; if (isPortfolio && !instrument) return; const quote = await dataProvider.getQuote(ticker.metadata.ticker, ticker.metadata.exchange, instrument ? toMarketDataContext(instrument) : undefined); quotes.set(ticker.metadata.ticker, quote); } catch { // Ignore partial quote failures so the rest of the table still renders. } }), ); if (!structured) { console.log(cliStyles.bold(displayName + (isPortfolio ? ` (${currency})` : ""))); console.log(cliStyles.muted(`${filtered.length} ticker${filtered.length === 1 ? "" : "s"}`)); console.log(""); } if (isPortfolio) { let totalPnl = 0; const unavailablePnl = new Set(); const unavailableCost = new Set(); const unavailableMarketValue = new Set(); const brokerPnlSymbols = new Set(); const rows: string[][] = []; const positionsExport: Record[] = []; const known = (value: number | null | undefined): number | null => value != null && Number.isFinite(value) ? value : null; for (const ticker of filtered) { const quote = quotes.get(ticker.metadata.ticker); const positions = ticker.metadata.positions.filter((position) => position.portfolio === id && position.shares !== 0); const displayedQuote = getActiveQuoteDisplay(quote); const activeQuote = displayedQuote && Number.isFinite(displayedQuote.price) ? displayedQuote : null; const priceText = quote && activeQuote ? colorBySign(formatMarketPriceWithCurrency(activeQuote.price, quote.currency, priceFormatOptions(quote, ticker.metadata.assetCategory)), activeQuote.change) : "—"; const changeText = activeQuote ? colorBySign(formatSignedPercentRaw(activeQuote.changePercent), activeQuote.change) : "—"; if (positions.length === 0) { rows.push([ticker.metadata.ticker, priceText, changeText, "—", "—", "—"]); positionsExport.push({ symbol: ticker.metadata.ticker, exchange: ticker.metadata.exchange, shares: null, avgCost: null, positionCurrency: null, quotePrice: known(activeQuote?.price), quoteCurrency: quote?.currency ?? null, costBasis: null, marketValue: null, unrealizedPnl: null, baseCurrency }); continue; } for (const position of positions) { const quoteCurrency = quote?.currency ?? ticker.metadata.currency ?? baseCurrency; const metrics = getPortfolioPositionMetrics({ ...ticker, metadata: { ...ticker.metadata, positions: [position] } }, id, quoteCurrency, undefined, quote); const valuationQuote = getPortfolioQuoteDisplay(metrics, quote); const positionCurrency = metrics.positionCurrency; const costBasisBase = positionCurrency ? await toBase(metrics.totalCost, positionCurrency) : Number.NaN; const positionRate = positionCurrency ? await toBase(1, positionCurrency) : Number.NaN; const baseMetrics = getPortfolioPositionMetrics({ ...ticker, metadata: { ...ticker.metadata, positions: [position] } }, id, quoteCurrency, { currency: baseCurrency, convert: value => value * positionRate }, quote); const currentValueBase = resolvePortfolioMarketValue(baseMetrics, valuationQuote ? await toBase(valuationQuote.price, quoteCurrency) : null)?.gross ?? null; const selectedPnl = resolvePortfolioPositionPnl(baseMetrics, valuationQuote ? await toBase(valuationQuote.price, quoteCurrency) : null); const pnl = selectedPnl.value; if (!metrics.hasCostBasis) unavailableCost.add(ticker.metadata.ticker); if (known(currentValueBase) === null) unavailableMarketValue.add(ticker.metadata.ticker); if (selectedPnl.basis === "broker-snapshot" || selectedPnl.basis === "mixed") brokerPnlSymbols.add(ticker.metadata.ticker); if (pnl != null && Number.isFinite(pnl)) totalPnl += pnl; else unavailablePnl.add(ticker.metadata.ticker); const direction = metrics.totalShares < 0 ? -1 : 1; positionsExport.push({ symbol: ticker.metadata.ticker, exchange: ticker.metadata.exchange, shares: metrics.totalShares, avgCost: known(position.avgCost), positionCurrency, priceBasis: position.priceBasis ?? null, quantityUnit: metrics.priceBasis === "percent-of-par" ? "face" : null, quotePriceBasis: quote?.priceBasis ?? null, quotePrice: known(activeQuote?.price), quoteCurrency, quoteAsOf: quote?.lastUpdated ?? null, costBasis: known(direction * costBasisBase), marketValue: currentValueBase == null ? null : known(direction * currentValueBase), unrealizedPnl: known(pnl), baseCurrency, dateAcquired: position.dateAcquired ?? null, pnlBasis: selectedPnl.basis, brokerUnrealizedPnl: known(position.unrealizedPnl), brokerPnlCurrency: positionCurrency, brokerPnlAsOf: null }); rows.push([ ticker.metadata.ticker, priceText, changeText, formatMarketQuantity(metrics.totalShares, { assetCategory: ticker.metadata.assetCategory, multiplier: position.multiplier, priceBasis: metrics.priceBasis, quantityCurrency: positionCurrency }), formatMarketCostWithCurrency(position.avgCost, positionCurrency, { assetCategory: ticker.metadata.assetCategory, multiplier: position.multiplier, priceBasis: metrics.priceBasis }), pnl == null || !Number.isFinite(pnl) ? "—" : colorBySign(formatSignedCurrency(pnl, baseCurrency), pnl), ]); } } const accountingBasis = "Unrealized P&L on current positions; excludes realized trades, distributions and cash flows. This is not account investment return."; const manualAccounting = !matchedPortfolio?.brokerId && !matchedPortfolio?.brokerInstanceId ? "Manual holdings are snapshots. Reconcile shares and per-share cost after corporate actions using portfolio position set; cash distributions are not credited." : null; if (structured) { ctx.printResult({ data: positionsExport, metadata: { portfolioId: id, portfolioName: displayName, baseCurrency, totalUnrealizedPnl: unavailablePnl.size > 0 ? null : totalPnl, complete: unavailablePnl.size === 0 && unavailableCost.size === 0 && unavailableMarketValue.size === 0, unavailableSymbols: [...new Set([...unavailablePnl, ...unavailableCost, ...unavailableMarketValue])], unavailableCostSymbols: [...unavailableCost], brokerPnlSymbols: [...brokerPnlSymbols], accountingBasis, ...(manualAccounting ? { manualAccounting } : {}), } }); return; } console.log(renderTable( [ { header: "Ticker" }, { header: "Last", align: "right" }, { header: "Chg", align: "right" }, { header: filtered.some(ticker => ticker.metadata.assetCategory?.toUpperCase() === "BOND") || positionsExport.some(row => row.quantityUnit === "face") ? "Qty" : "Shares", align: "right" }, { header: "Avg Cost", align: "right" }, { header: "P&L", align: "right" }, ], rows, )); console.log(""); console.log(renderStats([[brokerPnlSymbols.size ? "Total P&L (incl. broker snapshots)" : "Total P&L", unavailablePnl.size > 0 ? "—" : colorBySign(formatSignedCurrency(totalPnl, baseCurrency), totalPnl)]])); if (unavailableCost.size > 0) console.log(cliStyles.muted(`Cost unavailable for ${[...unavailableCost].join(", ")}.`)); if (unavailablePnl.size > 0) console.log(cliStyles.muted(`P&L unavailable for ${[...unavailablePnl].join(", ")}.`)); } else { const rows: string[][] = []; for (const ticker of filtered) { const quote = quotes.get(ticker.metadata.ticker); const priceText = quote ? colorBySign(formatMarketPriceWithCurrency(quote.price, quote.currency, priceFormatOptions(quote, ticker.metadata.assetCategory)), quote.change) : "—"; const changeText = quote ? colorBySign(formatSignedPercentRaw(quote.changePercent), quote.change) : "—"; const marketCapText = quote?.marketCap != null ? `${formatCompact(await toBase(quote.marketCap, quote.currency || ticker.metadata.currency || baseCurrency))} ${baseCurrency}` : "—"; rows.push([ ticker.metadata.ticker, exchangeShortName(quote?.exchangeName, quote?.fullExchangeName) || ticker.metadata.exchange || "—", priceText, changeText, marketCapText, ]); } console.log(renderTable( [ { header: "Ticker" }, { header: "Exchange" }, { header: "Last", align: "right" }, { header: "Chg", align: "right" }, { header: "Mkt Cap", align: "right" }, ], rows, )); } }