diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index a9a3f67..7fc07ac 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -5,6 +5,8 @@ const framework = @import("framework.zig"); const fmt = cli.fmt; const Money = @import("../Money.zig"); const views = @import("../views/portfolio_sections.zig"); +/// Main holdings-table column layout (widths + format strings). +const pl = views.PositionsLayout; /// Visibility of expired options / matured CDs within the Options /// and CDs sections. @@ -308,12 +310,8 @@ pub fn display( // Column headers try out.print("\n", .{}); try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" " ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{ - "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account", - }); - try out.print(" " ++ std.fmt.comptimePrint("{{s:->{d}}}", .{fmt.sym_col_width}) ++ " {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8} {s:->13} {s:->8}\n", .{ - "", "", "", "", "", "", "", "", "", - }); + try out.print(pl.header, pl.header_labels); + try out.print(pl.separator, pl.separator_fills); try cli.reset(out, color); // Position rows with lot detail @@ -345,19 +343,19 @@ pub fn display( } if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING); - try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {f} ", .{ - a.display_symbol, a.shares, Money.from(a.avg_cost).padRight(10), + try out.print(" " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " {f} ", .{ + a.display_symbol, a.shares, Money.from(a.avg_cost).padRight(pl.price_w), }); - try out.print("{f}", .{Money.from(a.current_price).padRight(10)}); - try out.print(" {f} ", .{Money.from(a.market_value).padRight(16)}); + try out.print("{f}", .{Money.from(a.current_price).padRight(pl.price_w)}); + try out.print(" {f} ", .{Money.from(a.market_value).padRight(pl.value_w)}); try cli.setGainLoss(out, color, a.unrealized_gain_loss); - try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(13) }); + try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(pl.gainloss_w - 1) }); if (a.is_manual_price) { try cli.setFg(out, color, cli.CLR_WARNING); } else { try cli.reset(out, color); } - try out.print(" {d:>7.1}%", .{a.weight * 100.0}); + try out.print(" " ++ pl.weight_num, .{a.weight * 100.0}); if (date_col_len > 0) { try out.print(" {s}", .{date_col[0..date_col_len]}); } @@ -411,19 +409,17 @@ pub fn display( } // Totals line - try cli.printFg(out, color, cli.CLR_MUTED, " {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{ - "", "", "", "", "", "", "", - }); + try cli.printFg(out, color, cli.CLR_MUTED, pl.total_sep, pl.total_sep_fills); { const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss; - try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {f} ", .{ - "", "", "", "TOTAL", Money.from(summary.total_value).padRight(16), + try out.print(pl.total, .{ + "", "", "", "TOTAL", Money.from(summary.total_value).padRight(pl.value_w), }); try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "{c}{f}", .{ @as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'), - Money.from(gl_abs).padRight(13), + Money.from(gl_abs).padRight(pl.gainloss_w - 1), }); - try out.print(" {s:>7}\n", .{"100.0%"}); + try out.print(" " ++ pl.weight_str ++ "\n", .{"100.0%"}); } if (summary.realized_gain_loss != 0) { @@ -593,7 +589,7 @@ pub fn display( std.fmt.bufPrint(&price_str, "{f}", .{Money.from(close)}) catch "$?" else "--"; - try out.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps }); + try out.print(" " ++ pl.symbol_spec ++ " " ++ pl.price_str ++ "\n", .{ sym, ps }); } } @@ -616,11 +612,11 @@ pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin const lot_gl_abs = if (gl >= 0) gl else -gl; const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; - try cli.printFg(out, color, cli.CLR_MUTED, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {f} {s:>10} {f} ", .{ - status_str, lot.shares, Money.from(lot.open_price).padRight(10), "", Money.from(lot.shares * use_price).padRight(16), + try cli.printFg(out, color, cli.CLR_MUTED, " " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " {f} " ++ pl.price_str ++ " {f} ", .{ + status_str, lot.shares, Money.from(lot.open_price).padRight(pl.price_w), "", Money.from(lot.shares * use_price).padRight(pl.value_w), }); - try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(13) }); - try cli.printFg(out, color, cli.CLR_MUTED, " {s:>8} {f} {s} {s}\n", .{ "", lot.open_date, indicator, acct_col }); + try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(pl.gainloss_w - 1) }); + try cli.printFg(out, color, cli.CLR_MUTED, " " ++ pl.weight_str ++ " {f} {s} {s}\n", .{ "", lot.open_date, indicator, acct_col }); } // ── Tests ──────────────────────────────────────────────────── @@ -718,6 +714,98 @@ test "display shows header and summary" { try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); } +test "display widens columns for large crypto values" { + // Regression guard for the portfolio-table column widening. A long + // crypto symbol (DOGE-USD, 8 chars), a large share count, and a high + // price must render in full without overflowing or shifting the + // symbol / shares / price / value columns. + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + + var lots = [_]zfin.Lot{ + .{ .symbol = "DOGE-USD", .shares = 10000, .open_date = zfin.Date.fromYmd(2024, 1, 2), .open_price = 41500.0, .account = "Sample Account" }, + }; + var portfolio = testPortfolio(&lots); + + var positions = [_]zfin.Position{ + .{ .symbol = "DOGE-USD", .shares = 10000, .avg_cost = 41500.0, .total_cost = 415000000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, + }; + + var allocs = [_]zfin.valuation.Allocation{ + .{ .symbol = "DOGE-USD", .display_symbol = "DOGE-USD", .shares = 10000, .avg_cost = 41500.0, .current_price = 42000.0, .market_value = 420000000.0, .cost_basis = 415000000.0, .weight = 1.0, .unrealized_gain_loss = 5000000.0, .unrealized_return = 0.01205 }, + }; + const summary = testSummary(&allocs); + + var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); + defer candle_map.deinit(); + const pf_data = testPortfolioData(summary, candle_map); + + var watch_prices = std.StringHashMap(f64).init(testing.allocator); + defer watch_prices.deinit(); + const watch_syms: []const []const u8 = &.{}; + + try display(testing.allocator, &w, false, "crypto.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup); + const out = w.buffered(); + + // Full 8-char symbol present (not truncated). + try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD") != null); + // Symbol column is wide enough that DOGE-USD does not butt up against + // the next column: it is followed by >= 2 spaces (in-column padding + // plus the separator). At the old 7-wide column there would be only + // the single separator space, so this catches a narrow-column regression. + try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD ") != null); + // Large share count, high price, and large market value render in full. + try testing.expect(std.mem.indexOf(u8, out, "10000.0") != null); + try testing.expect(std.mem.indexOf(u8, out, "$42,000.00") != null); + try testing.expect(std.mem.indexOf(u8, out, "$420,000,000.00") != null); + // Gain/Loss of $5M fits its column without truncation. + try testing.expect(std.mem.indexOf(u8, out, "$5,000,000.00") != null); + // No ANSI codes when color is disabled. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); + + // The single position's market value equals the portfolio total, so + // it must appear at the same column in the data row and the TOTAL + // row. This guards the TOTAL line against drifting out of alignment + // when the data columns are widened. + var data_col: ?usize = null; + var total_col: ?usize = null; + var lit = std.mem.splitScalar(u8, out, '\n'); + while (lit.next()) |line| { + const at = std.mem.indexOf(u8, line, "$420,000,000.00") orelse continue; + if (std.mem.indexOf(u8, line, "DOGE-USD") != null) { + data_col = at; + } else if (std.mem.indexOf(u8, line, "TOTAL") != null) { + total_col = at; + } + } + try testing.expect(data_col != null); + try testing.expect(total_col != null); + try testing.expectEqual(data_col.?, total_col.?); + + // The Date column is left-justified text (like the symbol column), so + // the "Date" header sits over the START of the date values, and the + // Account column lines up too. Verify the header label and the data + // value share a column. + var hdr_date: ?usize = null; + var row_date: ?usize = null; + var hdr_acct: ?usize = null; + var row_acct: ?usize = null; + var dit = std.mem.splitScalar(u8, out, '\n'); + while (dit.next()) |line| { + if (std.mem.indexOf(u8, line, "Symbol") != null) { + hdr_date = std.mem.indexOf(u8, line, "Date"); + hdr_acct = std.mem.indexOf(u8, line, "Account"); + } else if (std.mem.indexOf(u8, line, "DOGE-USD") != null) { + row_date = std.mem.indexOf(u8, line, "2024-01-02"); + row_acct = std.mem.indexOf(u8, line, "Sample Account"); + } + } + try testing.expect(hdr_date != null and row_date != null); + try testing.expectEqual(hdr_date.?, row_date.?); + try testing.expect(hdr_acct != null and row_acct != null); + try testing.expectEqual(hdr_acct.?, row_acct.?); +} + test "display with watchlist" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); diff --git a/src/format.zig b/src/format.zig index cc094f0..00f038f 100644 --- a/src/format.zig +++ b/src/format.zig @@ -16,11 +16,6 @@ const PerformanceResult = @import("analytics/performance.zig").PerformanceResult // ── Layout constants ───────────────────────────────────────── -/// Width of the symbol column in portfolio view (CLI + TUI). -pub const sym_col_width = 7; -/// Comptime format spec for left-aligned symbol column, e.g. "{s:<7}". -pub const sym_col_spec = std.fmt.comptimePrint("{{s:<{d}}}", .{sym_col_width}); - /// Width of the account name column in cash section (CLI + TUI). pub const cash_acct_width = 30; diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 87113e5..3f610a7 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -4,6 +4,8 @@ const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const views = @import("../views/portfolio_sections.zig"); +/// Main holdings-table column layout (widths + per-column specs). +const pl = views.PositionsLayout; const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const framework = @import("tab_framework.zig"); @@ -13,10 +15,14 @@ const StyledLine = tui.StyledLine; const colLabel = tui.colLabel; // Portfolio column layout (display columns). -// Each column width includes its trailing separator space. -// prefix(4) + sym(sw+1) + shares(8+1) + avgcost(10+1) + price(10+1) + mv(16+1) + gl(14+1) + weight(8) + date(13+1) + account +// Row shape: prefix(4) + sym + shares + avgcost + price + mv + gl + weight + date + account. +// Every non-prefix column width is owned by views/portfolio_sections.zig +// (PositionsLayout: symbol_w, shares_w, price_w, value_w, gainloss_w, +// weight_w, date_w); each column adds a 1-col separator space. The +// row/header format strings and the col_end_* hit-test offsets below all +// derive from those same widths, so they can't drift apart. const prefix_cols: usize = 4; -const sw: usize = fmt.sym_col_width; +const sw: usize = pl.symbol_w; // ── Portfolio-specific types ────────────────────────────────── @@ -775,14 +781,17 @@ fn toggleExpandAtCursor(state: *State, app: *App) void { } /// Cumulative column end positions for click-to-sort hit testing. -pub const col_end_symbol: usize = prefix_cols + sw + 1; -pub const col_end_shares: usize = col_end_symbol + 9; -pub const col_end_avg_cost: usize = col_end_shares + 11; -pub const col_end_price: usize = col_end_avg_cost + 11; -pub const col_end_market_value: usize = col_end_price + 17; -pub const col_end_gain_loss: usize = col_end_market_value + 15; -pub const col_end_weight: usize = col_end_gain_loss + 9; -pub const col_end_date: usize = col_end_weight + 14; +/// Each offset is the previous column's end plus this column's content +/// width (from format.zig) plus the 1-col separator space, so the +/// hit-test grid stays locked to the row/header format strings. +const col_end_symbol: usize = prefix_cols + sw + 1; +const col_end_shares: usize = col_end_symbol + pl.shares_w + 1; +const col_end_avg_cost: usize = col_end_shares + pl.price_w + 1; +const col_end_price: usize = col_end_avg_cost + pl.price_w + 1; +const col_end_market_value: usize = col_end_price + pl.value_w + 1; +const col_end_gain_loss: usize = col_end_market_value + pl.gainloss_w + 1; +const col_end_weight: usize = col_end_gain_loss + pl.weight_w + 1; +const col_end_date: usize = col_end_weight + pl.date_w + 1; // Gain/loss column start position (used for alt-style coloring) const gl_col_start: usize = col_end_market_value; @@ -1661,16 +1670,16 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va var mv_hdr_buf: [24]u8 = undefined; var gl_hdr_buf: [24]u8 = undefined; var wt_hdr_buf: [16]u8 = undefined; - const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", fmt.sym_col_width, true, if (sf == .symbol) si else null); - const shr_hdr = colLabel(&shr_hdr_buf, "Shares", 8, false, if (sf == .shares) si else null); - const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", 10, false, if (sf == .avg_cost) si else null); - const prc_hdr = colLabel(&prc_hdr_buf, "Price", 10, false, if (sf == .price) si else null); - const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", 16, false, if (sf == .market_value) si else null); - const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", 14, false, if (sf == .gain_loss) si else null); - const wt_hdr = colLabel(&wt_hdr_buf, "Weight", 8, false, if (sf == .weight) si else null); + const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", pl.symbol_w, true, if (sf == .symbol) si else null); + const shr_hdr = colLabel(&shr_hdr_buf, "Shares", pl.shares_w, false, if (sf == .shares) si else null); + const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", pl.price_w, false, if (sf == .avg_cost) si else null); + const prc_hdr = colLabel(&prc_hdr_buf, "Price", pl.price_w, false, if (sf == .price) si else null); + const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", pl.value_w, false, if (sf == .market_value) si else null); + const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", pl.gainloss_w, false, if (sf == .gain_loss) si else null); + const wt_hdr = colLabel(&wt_hdr_buf, "Weight", pl.weight_w, false, if (sf == .weight) si else null); const acct_ind: []const u8 = if (sf == .account) si else ""; - const hdr = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} {s} {s:>13} {s}{s}", .{ + const hdr = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} {s} " ++ pl.date_str ++ " {s}{s}", .{ sym_hdr, shr_hdr, avg_hdr, prc_hdr, mv_hdr, gl_hdr, wt_hdr, "Date", acct_ind, "Account", }); try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() }); @@ -1751,7 +1760,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va else a.weight; - const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{ + const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_num ++ " " ++ pl.date_str ++ " {s}", .{ arrow, star, a.display_symbol, display_shares, cost_str, price_str, mv_str, pnl_str, display_weight * 100.0, date_col, acct_col, }); @@ -1765,7 +1774,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va .style = base_style, .alt_style = gl_style, .alt_start = gl_col_start, - .alt_end = gl_col_start + 14, + .alt_end = gl_col_start + pl.gainloss_w, }); } } @@ -1799,7 +1808,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date); const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator }); const acct_col: []const u8 = lot.account orelse ""; - const text = try std.fmt.allocPrint(arena, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{ + const text = try std.fmt.allocPrint(arena, " " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_str ++ " " ++ pl.date_str ++ " {s}", .{ status_str, lot.shares, lot_price_str, "", lot_mv_str, lot_gl_str, "", lot_date_col, acct_col, }); const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle(); @@ -1809,7 +1818,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va .style = base_style, .alt_style = gl_col_style, .alt_start = gl_col_start, - .alt_end = gl_col_start + 14, + .alt_end = gl_col_start + pl.gainloss_w, }); } }, @@ -1820,7 +1829,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va else "--"; const star2: []const u8 = if (is_active_sym) "* " else " "; - const text = try std.fmt.allocPrint(arena, " {s}" ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{ + const text = try std.fmt.allocPrint(arena, " {s}" ++ pl.symbol_spec ++ " " ++ pl.shares_str ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_str ++ " " ++ pl.date_str, .{ star2, row.symbol, "--", "--", ps, "--", "--", "watch", "", }); const row_style = if (is_cursor) th.selectStyle() else th.contentStyle(); diff --git a/src/views/portfolio_sections.zig b/src/views/portfolio_sections.zig index 44ef5d2..fa3cb64 100644 --- a/src/views/portfolio_sections.zig +++ b/src/views/portfolio_sections.zig @@ -1,4 +1,4 @@ -//! View models for portfolio sections (Options, CDs). +//! View models for portfolio sections (Positions, Options, CDs). //! Produces renderer-agnostic display data consumed by both CLI and TUI. //! Column widths, format strings, computed values, and style decisions //! are defined here. Renderers are thin adapters that map StyleIntent @@ -10,6 +10,86 @@ const Date = @import("../Date.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); +// ── Positions (main holdings table) ─────────────────────────── + +/// Column layout for the main holdings table - the portfolio's primary +/// table, shared by the CLI (commands/portfolio.zig) and the TUI +/// (tui/portfolio_tab.zig). This is the single source of truth for the +/// column widths, so the CLI header/separator/rows, the TUI header/rows, +/// and the TUI click-to-sort hit-test offsets (portfolio_tab.col_end_*) +/// all derive from the same numbers and can't drift apart. +/// +/// Unlike the Options / CDs layouts, the rows don't collapse into one +/// `data_row` string: the gain/loss cell is colored (so the CLI splits +/// the row across several prints) and the shares / weight cells render a +/// float inline in data rows but a pre-formatted string in headers and +/// the TUI's pre-rendered cells. So the per-column specs are exposed +/// individually alongside the composed header / separator / total +/// strings. +pub const PositionsLayout = struct { + const cp = std.fmt.comptimePrint; + + // Column content widths. The 1-col separating space between columns + // is added at layout time, not included here. + pub const symbol_w = 10; + pub const shares_w = 12; + pub const price_w = 12; // avg cost and current price + pub const value_w = 16; // market value + pub const gainloss_w = 14; // sign + amount + pub const weight_w = 8; // "NNN.N%" + pub const date_w = 13; + pub const account_w = 8; // separator rule only; header/data are natural width + + // Per-column format specs. `symbol_spec` and `date_str` left-justify + // their text columns (symbol and date read left-to-right, so their + // headers sit over the start of the data); the other specs right- + // justify their numeric columns. `shares_num` / `weight_num` render + // a float in place (data rows); the `*_str` specs right-justify a + // pre-formatted string (headers and the TUI's pre-rendered cells). + pub const symbol_spec = cp("{{s:<{d}}}", .{symbol_w}); + pub const shares_num = cp("{{d:>{d}.1}}", .{shares_w}); + pub const weight_num = cp("{{d:>{d}.1}}%", .{weight_w - 1}); + pub const shares_str = cp("{{s:>{d}}}", .{shares_w}); + pub const price_str = cp("{{s:>{d}}}", .{price_w}); + pub const value_str = cp("{{s:>{d}}}", .{value_w}); + pub const gainloss_str = cp("{{s:>{d}}}", .{gainloss_w}); + pub const weight_str = cp("{{s:>{d}}}", .{weight_w}); + pub const date_str = cp("{{s:<{d}}}", .{date_w}); + + // CLI header + dashed separator (9 columns, see header_labels). + pub const header = " " ++ symbol_spec ++ " " ++ shares_str ++ " " ++ price_str ++ + " " ++ price_str ++ " " ++ value_str ++ " " ++ gainloss_str ++ + " " ++ weight_str ++ " " ++ date_str ++ " {s}\n"; + pub const header_labels = .{ "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account" }; + pub const separator = " " ++ cp("{{s:->{d}}}", .{symbol_w}) ++ + " " ++ cp("{{s:->{d}}}", .{shares_w}) ++ + " " ++ cp("{{s:->{d}}}", .{price_w}) ++ + " " ++ cp("{{s:->{d}}}", .{price_w}) ++ + " " ++ cp("{{s:->{d}}}", .{value_w}) ++ + " " ++ cp("{{s:->{d}}}", .{gainloss_w}) ++ + " " ++ cp("{{s:->{d}}}", .{weight_w}) ++ + " " ++ cp("{{s:->{d}}}", .{date_w}) ++ + " " ++ cp("{{s:->{d}}}", .{account_w}) ++ "\n"; + pub const separator_fills = .{ "", "", "", "", "", "", "", "", "" }; + + // CLI TOTAL row: a sum rule spanning Symbol..Weight, then the totals + // row. `total` blanks the symbol / shares / avg-cost cells, right- + // justifies "TOTAL" in the price cell, and renders the market-value + // cell (4 string args + the market-value `{f}`); the caller prints + // the gain/loss and weight cells separately because gain/loss needs + // color. + pub const total_sep = " " ++ cp("{{s:->{d}}}", .{symbol_w}) ++ + " " ++ cp("{{s:->{d}}}", .{shares_w}) ++ + " " ++ cp("{{s:->{d}}}", .{price_w}) ++ + " " ++ cp("{{s:->{d}}}", .{price_w}) ++ + " " ++ cp("{{s:->{d}}}", .{value_w}) ++ + " " ++ cp("{{s:->{d}}}", .{gainloss_w}) ++ + " " ++ cp("{{s:->{d}}}", .{weight_w}) ++ "\n"; + pub const total_sep_fills = .{ "", "", "", "", "", "", "" }; + pub const total = " " ++ symbol_spec ++ " " ++ shares_str ++ " " ++ price_str ++ + " " ++ price_str ++ " {f} "; +}; + // ── Options ─────────────────────────────────────────────────── /// Column layout for the Options section.