import { t } from "../../../../i18n"; import { formatMarketPriceWithCurrency, liveQuoteFormatOptions, type MarketFormatOptions } from "../../../../market-data/market/format"; import { colors, priceColor } from "../../../../theme/colors"; import type { Quote } from "../../../../types/financials"; import { Box, Text, useUiHost } from "../../../../ui"; import { displayWidth, formatNumber, padTo } from "../../../../utils/format"; import type { PositionTableRow, StatField } from "./types"; import { portfolioPnlLabel } from "../../portfolio-list/position-metrics"; const STAT_COLUMN_GAP = 2; const STAT_LABEL_WIDTH = 12; const FUNDAMENTALS_MAX_COLUMN_WIDTH = 40; const BOOK_LABEL_WIDTH = 4; const RANGE_ENDPOINT_WIDTH = 11; const POSITION_COLUMN_GAP = 1; interface PositionColumn { key: keyof Omit; label: string; width: number; align?: "left" | "right"; color?: (row: PositionTableRow) => string; /** Lowest pane width that still has room for this column. */ minPaneWidth?: number; } function RangeTrack({ barWidth, markerIndex, position, markerColor, }: { barWidth: number; markerIndex: number; position: number; markerColor: string; }) { // The desktop webview must not draw rules out of box-drawing glyphs. if (useUiHost().kind === "desktop-web") { return ( ); } return ( {"\u2500".repeat(markerIndex)} {"\u25cf"} {"\u2500".repeat(Math.max(0, barWidth - markerIndex - 1))} ); } export function CompactRangeBar({ current, low, high, label, width, currency, priceOptions, markerColor, }: { current: number; low: number; high: number; label: string; width: number; currency: string; /** The quote's live price options, so the endpoints keep the price's decimals. */ priceOptions: MarketFormatOptions; markerColor: string; }) { const range = high - low; if (range <= 0) return null; const position = Math.max(0, Math.min(1, (current - low) / range)); const pctLabel = `${Math.round(position * 100)}%`; const endpointWidth = Math.min( RANGE_ENDPOINT_WIDTH, Math.max(7, Math.floor((width - 8) / 3)), ); const endpointOptions = { ...priceOptions, maxWidth: endpointWidth }; const lowText = formatMarketPriceWithCurrency(low, currency, endpointOptions); const highText = formatMarketPriceWithCurrency(high, currency, endpointOptions); const barWidth = Math.max(5, width - endpointWidth * 2 - 2); const markerIndex = Math.max(0, Math.min(barWidth - 1, Math.round(position * (barWidth - 1)))); const labelWidth = Math.max(0, width - displayWidth(pctLabel)); return ( {label} {pctLabel} {lowText} {highText} ); } function BookRow({ label, value, width, valueColor, }: { label: string; value: string; width: number; valueColor: string; }) { return ( {padTo(label, BOOK_LABEL_WIDTH)} {/* Right-aligned, so a size changing length does not slide the price. */} {padTo(value, Math.max(0, width - BOOK_LABEL_WIDTH), "right")} ); } export function QuoteBook({ quote, assetCategory, width }: { quote: Quote; assetCategory?: string; width: number }) { // Bid, ask and spread keep the instrument's decimals on every tick ($224.58 / $224.60 / $0.02). const priceOptions = liveQuoteFormatOptions(quote, quote.currency, assetCategory); const bidPrice = quote.bid != null ? formatMarketPriceWithCurrency(quote.bid, quote.currency, priceOptions) : "—"; const askPrice = quote.ask != null ? formatMarketPriceWithCurrency(quote.ask, quote.currency, priceOptions) : "—"; const bidText = quote.bidSize != null && quote.bidSize > 0 ? `${formatNumber(quote.bidSize, 0)} x ${bidPrice}` : bidPrice; const askText = quote.askSize != null && quote.askSize > 0 ? `${formatNumber(quote.askSize, 0)} x ${askPrice}` : askPrice; let spreadText = "—"; if (quote.bid != null && quote.ask != null) { const spread = quote.ask - quote.bid; const mid = (quote.ask + quote.bid) / 2; const spreadPercent = mid > 0 ? ` (${((spread / mid) * 100).toFixed(2)}%)` : ""; spreadText = `${formatMarketPriceWithCurrency(spread, quote.currency, priceOptions)}${spreadPercent}`; } return ( ); } /** * The overview's fundamentals as label/value pairs in columns. Columns are * capped at FUNDAMENTALS_MAX_COLUMN_WIDTH so a wide pane gets more of them * instead of pushing each value half a pane away from its label. */ export function fundamentalsGridColumns(width: number): number { if (width < 58) return 1; return Math.max(2, Math.min(4, Math.ceil((width + STAT_COLUMN_GAP) / (FUNDAMENTALS_MAX_COLUMN_WIDTH + STAT_COLUMN_GAP)))); } export function FundamentalsGrid({ fields, width }: { fields: StatField[]; width: number }) { const columnCount = fundamentalsGridColumns(width); const availableWidth = width - STAT_COLUMN_GAP * (columnCount - 1); const baseColWidth = Math.floor(availableWidth / columnCount); const extraWidth = availableWidth - baseColWidth * columnCount; const colWidths = Array.from({ length: columnCount }, (_, index) => baseColWidth + (index === columnCount - 1 ? extraWidth : 0)); const rows: Array> = []; for (let i = 0; i < fields.length; i += columnCount) { rows.push(Array.from({ length: columnCount }, (_, offset) => fields[i + offset] ?? null)); } return ( {rows.map((row, i) => ( {row.map((field, j) => { const colWidth = colWidths[j] ?? baseColWidth; if (!field) { return ( {j > 0 && } ); } const labelWidth = Math.min(STAT_LABEL_WIDTH, Math.max(8, Math.floor(colWidth * 0.45))); const valueWidth = Math.max(1, colWidth - labelWidth); return ( {j > 0 && } {t(field.label)} {field.value} ); })} ))} ); } const POSITION_COLUMNS: readonly PositionColumn[] = [ { key: "account", label: "Account", width: 0 }, { key: "qty", label: "Qty", width: 8, align: "right" }, { key: "avg", label: "Avg", width: 9, align: "right", minPaneWidth: 70 }, { key: "mark", label: "Mark", width: 9, align: "right", minPaneWidth: 70 }, { key: "cost", label: "Cost", width: 11, align: "right", minPaneWidth: 84 }, { key: "value", label: "Value", width: 11, align: "right" }, { key: "pnl", label: "P&L", width: 12, align: "right", color: (row) => priceColor(row.pnlValue ?? 0) }, { key: "ret", label: "Ret", width: 7, align: "right", color: (row) => priceColor(row.pnlValue ?? 0), minPaneWidth: 84 }, ]; function createPositionColumns(width: number, hasFaceQuantity: boolean): PositionColumn[] { const columns = POSITION_COLUMNS.filter((column) => width >= (column.minPaneWidth ?? 0)).map((column) => ({ ...column, width: column.key === "qty" && hasFaceQuantity ? 11 : column.width, })); const fixedWidth = columns.reduce((sum, column) => sum + column.width, 0) + POSITION_COLUMN_GAP * (columns.length - 1); const accountColumn = columns[0]!; accountColumn.width = Math.max(8, width - fixedWidth); return columns; } export function PositionTable({ rows, width }: { rows: PositionTableRow[]; width: number }) { const columns = createPositionColumns(width, rows.some(row => row.quantityUnit === "face")); const pnlLabel = portfolioPnlLabel(rows.map((row) => row.pnlBasis)); return ( {columns.map((column, index) => ( {index > 0 && } {padTo(t(column.key === "pnl" ? pnlLabel : column.label), column.width, column.align)} ))} {rows.map((row, rowIndex) => ( {columns.map((column, index) => ( {index > 0 && } {padTo(row[column.key], column.width, column.align)} ))} ))} ); }