From bbb11c29e1445a54990f841b9f4fca59dbd8bdc3 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 26 Feb 2026 14:10:19 -0800 Subject: [PATCH] ai: portfolio sorting --- src/analytics/risk.zig | 3 + src/cli/main.zig | 39 +++--- src/format.zig | 97 +++++++++++++++ src/models/portfolio.zig | 10 ++ src/tui/keybinds.zig | 6 + src/tui/main.zig | 251 ++++++++++++++++++++++++++++++++++++--- 6 files changed, 371 insertions(+), 35 deletions(-) diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index 6bb4411..6204223 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -108,6 +108,8 @@ pub const Allocation = struct { unrealized_return: f64, /// True if current_price came from a manual override rather than live API data. is_manual_price: bool = false, + /// Account name (from lots; "Multiple" if lots span different accounts). + account: []const u8 = "", }; /// Compute portfolio summary given positions and current prices. @@ -145,6 +147,7 @@ pub fn portfolioSummary( .unrealized_pnl = mv - pos.total_cost, .unrealized_return = if (pos.total_cost > 0) (mv / pos.total_cost) - 1.0 else 0, .is_manual_price = if (manual_prices) |mp| mp.contains(pos.symbol) else false, + .account = pos.account, }); } diff --git a/src/cli/main.zig b/src/cli/main.zig index 6e16427..e23f431 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -1160,6 +1160,13 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da }; defer summary.deinit(allocator); + // Sort allocations alphabetically by symbol + std.mem.sort(zfin.risk.Allocation, summary.allocations, {}, struct { + fn f(_: void, a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool { + return std.mem.lessThan(u8, a.symbol, b.symbol); + } + }.f); + // Include non-stock assets in the grand total const cash_total = portfolio.totalCash(); const cd_total = portfolio.totalCdFaceValue(); @@ -1218,10 +1225,10 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da // Column headers try out.print("\n", .{}); try setFg(out, color, CLR_MUTED); - try out.print(" {s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{ + 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(" {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8} {s:->13} {s:->8}\n", .{ + 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 reset(out, color); @@ -1261,7 +1268,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da date_col_len = written.len; } - try out.print(" {s:<6} {d:>8.1} {s:>10} ", .{ + try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{ a.symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost), }); if (a.is_manual_price) try setFg(out, color, CLR_YELLOW); @@ -1515,28 +1522,26 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da try out.print(" Cash\n", .{}); try reset(out, color); try setFg(out, color, CLR_MUTED); - try out.print(" {s:<20} {s:>14}\n", .{ "Account", "Balance" }); - try out.print(" {s:->20} {s:->14}\n", .{ "", "" }); + var cash_hdr_buf: [80]u8 = undefined; + try out.print("{s}\n", .{fmt.fmtCashHeader(&cash_hdr_buf)}); + var cash_sep_buf: [80]u8 = undefined; + try out.print("{s}\n", .{fmt.fmtCashSep(&cash_sep_buf)}); try reset(out, color); for (portfolio.lots) |lot| { if (lot.lot_type != .cash) continue; - var cash_buf: [24]u8 = undefined; const acct2: []const u8 = lot.account orelse "Unknown"; - try out.print(" {s:<20} {s:>14}\n", .{ - acct2, - fmt.fmtMoney(&cash_buf, lot.shares), - }); + var row_buf: [80]u8 = undefined; + try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares)}); } // Cash total + var sep_buf: [80]u8 = undefined; try setFg(out, color, CLR_MUTED); - try out.print(" {s:->20} {s:->14}\n", .{ "", "" }); + try out.print("{s}\n", .{fmt.fmtCashSep(&sep_buf)}); try reset(out, color); - var cash_total_buf: [24]u8 = undefined; + var total_buf: [80]u8 = undefined; try setBold(out, color); - try out.print(" {s:>20} {s:>14}\n", .{ - "TOTAL", fmt.fmtMoney(&cash_total_buf, portfolio.totalCash()), - }); + try out.print("{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash())}); try reset(out, color); } @@ -1569,7 +1574,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da ps2 = fmt.fmtMoney2(&price_str2, candles2[candles2.len - 1].close); } } - try o.print(" {s:<6} {s:>10}\n", .{ sym, ps2 }); + try o.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps2 }); } }.f; @@ -1667,7 +1672,7 @@ fn printCliLotRow(out: anytype, color: bool, lot: zfin.Lot, current_price: f64) const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; try setFg(out, color, CLR_MUTED); - try out.print(" {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{ + try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{ status_str, lot.shares, fmt.fmtMoney2(&lot_price_buf, lot.open_price), "", "", }); try reset(out, color); diff --git a/src/format.zig b/src/format.zig index 2256526..be61b54 100644 --- a/src/format.zig +++ b/src/format.zig @@ -9,6 +9,103 @@ const Candle = @import("models/candle.zig").Candle; const OptionContract = @import("models/option.zig").OptionContract; const Lot = @import("models/portfolio.zig").Lot; +// ── 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; + +/// Format the cash section column header: " Account Balance" +pub fn fmtCashHeader(buf: []u8) []const u8 { + const w = cash_acct_width; + var pos: usize = 0; + @memcpy(buf[0..2], " "); + pos += 2; + const acct_label = "Account"; + @memcpy(buf[pos..][0..acct_label.len], acct_label); + @memset(buf[pos + acct_label.len ..][0 .. w - acct_label.len], ' '); + pos += w; + buf[pos] = ' '; + pos += 1; + const bal_label = "Balance"; + const bal_pad = if (bal_label.len < 14) 14 - bal_label.len else 0; + @memset(buf[pos..][0..bal_pad], ' '); + pos += bal_pad; + @memcpy(buf[pos..][0..bal_label.len], bal_label); + pos += bal_label.len; + return buf[0..pos]; +} + +/// Format a cash row: " account_name $1,234.56" +/// Returns a slice of `buf`. +pub fn fmtCashRow(buf: []u8, account: []const u8, amount: f64) []const u8 { + var money_buf: [24]u8 = undefined; + const money = fmtMoney(&money_buf, amount); + const w = cash_acct_width; + // " {name:14}" + const prefix = " "; + var pos: usize = 0; + @memcpy(buf[pos..][0..prefix.len], prefix); + pos += prefix.len; + const name_len = @min(account.len, w); + @memcpy(buf[pos..][0..name_len], account[0..name_len]); + if (name_len < w) @memset(buf[pos + name_len ..][0 .. w - name_len], ' '); + pos += w; + buf[pos] = ' '; + pos += 1; + // Right-align money in 14 chars + const money_pad = if (money.len < 14) 14 - money.len else 0; + @memset(buf[pos..][0..money_pad], ' '); + pos += money_pad; + @memcpy(buf[pos..][0..money.len], money); + pos += money.len; + return buf[0..pos]; +} + +/// Format the cash total separator line. +pub fn fmtCashSep(buf: []u8) []const u8 { + const w = cash_acct_width; + var pos: usize = 0; + @memcpy(buf[0..2], " "); + pos += 2; + @memset(buf[pos..][0..w], '-'); + pos += w; + buf[pos] = ' '; + pos += 1; + @memset(buf[pos..][0..14], '-'); + pos += 14; + return buf[0..pos]; +} + +/// Format the cash total row. +pub fn fmtCashTotal(buf: []u8, total: f64) []const u8 { + var money_buf: [24]u8 = undefined; + const money = fmtMoney(&money_buf, total); + const w = cash_acct_width; + var pos: usize = 0; + @memcpy(buf[0..2], " "); + pos += 2; + // Right-align "TOTAL" in w chars + const label = "TOTAL"; + const label_pad = w - label.len; + @memset(buf[pos..][0..label_pad], ' '); + pos += label_pad; + @memcpy(buf[pos..][0..label.len], label); + pos += label.len; + buf[pos] = ' '; + pos += 1; + const money_pad = if (money.len < 14) 14 - money.len else 0; + @memset(buf[pos..][0..money_pad], ' '); + pos += money_pad; + @memcpy(buf[pos..][0..money.len], money); + pos += money.len; + return buf[0..pos]; +} + // ── Number formatters ──────────────────────────────────────── /// Format a dollar amount with commas and 2 decimals: $1,234.56 diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index d71560a..b8cbb1f 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -108,6 +108,8 @@ pub const Position = struct { closed_lots: u32, /// Total realized P&L from closed lots realized_pnl: f64, + /// Account name (shared across lots, or "Multiple" if mixed). + account: []const u8 = "", }; /// A portfolio is a collection of lots. @@ -211,7 +213,15 @@ pub const Portfolio = struct { .open_lots = 0, .closed_lots = 0, .realized_pnl = 0, + .account = lot.account orelse "", }; + } else { + // Track account: if lots have different accounts, mark as "Multiple" + const existing = entry.value_ptr.account; + const new_acct = lot.account orelse ""; + if (existing.len > 0 and !std.mem.eql(u8, existing, "Multiple") and !std.mem.eql(u8, existing, new_acct)) { + entry.value_ptr.account = "Multiple"; + } } if (lot.isOpen()) { entry.value_ptr.shares += lot.shares; diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 2ebe085..83a54e5 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -39,6 +39,9 @@ pub const Action = enum { options_filter_9, chart_timeframe_next, chart_timeframe_prev, + sort_col_next, + sort_col_prev, + sort_reverse, }; pub const KeyCombo = struct { @@ -117,6 +120,9 @@ const default_bindings = [_]Binding{ .{ .action = .options_filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } }, .{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } }, .{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } }, + .{ .action = .sort_col_next, .key = .{ .codepoint = '>' } }, + .{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } }, + .{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } }, }; pub fn defaults() KeyMap { diff --git a/src/tui/main.zig b/src/tui/main.zig index b0210be..5bf1d0b 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -17,6 +17,48 @@ const ascii_g = blk: { break :blk table; }; +/// Build a fixed-display-width column header label with optional sort indicator. +/// The indicator (▲/▼, 3 bytes, 1 display column) replaces a padding space so total +/// display width stays constant. Indicator always appears on the left side. +/// `left` controls text alignment (left-aligned vs right-aligned). +fn colLabel(buf: []u8, name: []const u8, comptime col_width: usize, left: bool, indicator: ?[]const u8) []const u8 { + const ind = indicator orelse { + // No indicator: plain padded label + if (left) { + @memset(buf[0..col_width], ' '); + @memcpy(buf[0..name.len], name); + return buf[0..col_width]; + } else { + @memset(buf[0..col_width], ' '); + const offset = col_width - name.len; + @memcpy(buf[offset..][0..name.len], name); + return buf[0..col_width]; + } + }; + // Indicator always on the left, replacing one padding space. + // total display cols = col_width, byte length = col_width - 1 + ind.len + const total_bytes = col_width - 1 + ind.len; + if (total_bytes > buf.len) return name; + if (left) { + // "▲Name " — indicator, text, then spaces + @memcpy(buf[0..ind.len], ind); + @memcpy(buf[ind.len..][0..name.len], name); + const content_len = ind.len + name.len; + if (content_len < total_bytes) @memset(buf[content_len..total_bytes], ' '); + } else { + // " ▲Name" — spaces, indicator, then text + const pad = col_width - name.len - 1; + @memset(buf[0..pad], ' '); + @memcpy(buf[pad..][0..ind.len], ind); + @memcpy(buf[pad + ind.len ..][0..name.len], name); + } + return buf[0..total_bytes]; +} + +// Portfolio column layout: gain/loss column start position (display columns). +// prefix(4) + sym(sym_col_width+1) + shares(9) + avgcost(11) + price(11) + mv(17) = 4 + sym_col_width + 49 +const gl_col_start: usize = 4 + fmt.sym_col_width + 49; + fn glyph(ch: u8) []const u8 { if (ch < 128) return ascii_g[ch]; return " "; @@ -55,6 +97,57 @@ const InputMode = enum { help, }; +/// Sort field for portfolio columns. +const PortfolioSortField = enum { + symbol, + shares, + avg_cost, + price, + market_value, + gain_loss, + weight, + account, + + pub fn label(self: PortfolioSortField) []const u8 { + return switch (self) { + .symbol => "Symbol", + .shares => "Shares", + .avg_cost => "Avg Cost", + .price => "Price", + .market_value => "Market Value", + .gain_loss => "Gain/Loss", + .weight => "Weight", + .account => "Account", + }; + } + + pub fn next(self: PortfolioSortField) ?PortfolioSortField { + const fields = std.meta.fields(PortfolioSortField); + const idx: usize = @intFromEnum(self); + if (idx + 1 >= fields.len) return null; + return @enumFromInt(idx + 1); + } + + pub fn prev(self: PortfolioSortField) ?PortfolioSortField { + const idx: usize = @intFromEnum(self); + if (idx == 0) return null; + return @enumFromInt(idx - 1); + } +}; + +const SortDirection = enum { + asc, + desc, + + pub fn flip(self: SortDirection) SortDirection { + return if (self == .asc) .desc else .asc; + } + + pub fn indicator(self: SortDirection) []const u8 { + return if (self == .asc) "▲" else "▼"; + } +}; + /// A row in the portfolio view -- position header, lot detail, or special sections. const PortfolioRow = struct { kind: Kind, @@ -135,6 +228,8 @@ const App = struct { portfolio_header_lines: usize = 0, // number of styled lines before data rows portfolio_line_to_row: [256]usize = [_]usize{0} ** 256, // maps styled line index -> portfolio_rows index portfolio_line_count: usize = 0, // total styled lines in portfolio view + portfolio_sort_field: PortfolioSortField = .symbol, // current sort column + portfolio_sort_dir: SortDirection = .asc, // current sort direction // Options navigation (inline expand/collapse like portfolio) options_cursor: usize = 0, // selected row in flattened options view @@ -271,6 +366,34 @@ const App = struct { } if (self.active_tab == .portfolio and mouse.row > 0) { const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; + // Click on column header row -> sort by that column + if (self.portfolio_header_lines > 0 and content_row == self.portfolio_header_lines - 1) { + // Column boundaries derived from sym_col_width (sw). + // prefix(4) + Symbol(sw+1) + Shares(8+1) + AvgCost(10+1) + Price(10+1) + MV(16+1) + G/L(14+1) + Weight(8) + const sw = fmt.sym_col_width; + const col = @as(usize, @intCast(mouse.col)); + const new_field: ?PortfolioSortField = + if (col < 4 + sw + 1) .symbol + else if (col < 4 + sw + 10) .shares + else if (col < 4 + sw + 21) .avg_cost + else if (col < 4 + sw + 32) .price + else if (col < 4 + sw + 49) .market_value + else if (col < 4 + sw + 64) .gain_loss + else if (col < 4 + sw + 73) .weight + else if (col < 4 + sw + 87) null // Date (not sortable) + else .account; + if (new_field) |nf| { + if (nf == self.portfolio_sort_field) { + self.portfolio_sort_dir = self.portfolio_sort_dir.flip(); + } else { + self.portfolio_sort_field = nf; + self.portfolio_sort_dir = if (nf == .symbol or nf == .account) .asc else .desc; + } + self.sortPortfolioAllocations(); + self.rebuildPortfolioRows(); + return ctx.consumeAndRedraw(); + } + } if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) { const line_idx = content_row - self.portfolio_header_lines; if (line_idx < self.portfolio_line_count and line_idx < self.portfolio_line_to_row.len) { @@ -531,6 +654,36 @@ const App = struct { return ctx.consumeAndRedraw(); } }, + .sort_col_next => { + if (self.active_tab == .portfolio) { + if (self.portfolio_sort_field.next()) |new_field| { + self.portfolio_sort_field = new_field; + self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc; + self.sortPortfolioAllocations(); + self.rebuildPortfolioRows(); + } + return ctx.consumeAndRedraw(); + } + }, + .sort_col_prev => { + if (self.active_tab == .portfolio) { + if (self.portfolio_sort_field.prev()) |new_field| { + self.portfolio_sort_field = new_field; + self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc; + self.sortPortfolioAllocations(); + self.rebuildPortfolioRows(); + } + return ctx.consumeAndRedraw(); + } + }, + .sort_reverse => { + if (self.active_tab == .portfolio) { + self.portfolio_sort_dir = self.portfolio_sort_dir.flip(); + self.sortPortfolioAllocations(); + self.rebuildPortfolioRows(); + return ctx.consumeAndRedraw(); + } + }, } } @@ -933,6 +1086,7 @@ const App = struct { } self.portfolio_summary = summary; + self.sortPortfolioAllocations(); self.rebuildPortfolioRows(); if (self.symbol.len == 0 and summary.allocations.len > 0) { @@ -975,6 +1129,31 @@ const App = struct { } } + fn sortPortfolioAllocations(self: *App) void { + if (self.portfolio_summary) |s| { + const SortCtx = struct { + field: PortfolioSortField, + dir: SortDirection, + + fn lessThan(ctx: @This(), a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool { + const lhs = if (ctx.dir == .asc) a else b; + const rhs = if (ctx.dir == .asc) b else a; + return switch (ctx.field) { + .symbol => std.mem.lessThan(u8, lhs.symbol, rhs.symbol), + .shares => lhs.shares < rhs.shares, + .avg_cost => lhs.avg_cost < rhs.avg_cost, + .price => lhs.current_price < rhs.current_price, + .market_value => lhs.market_value < rhs.market_value, + .gain_loss => lhs.unrealized_pnl < rhs.unrealized_pnl, + .weight => lhs.weight < rhs.weight, + .account => std.mem.lessThan(u8, lhs.account, rhs.account), + }; + } + }; + std.mem.sort(zfin.risk.Allocation, s.allocations, SortCtx{ .field = self.portfolio_sort_field, .dir = self.portfolio_sort_dir }, SortCtx.lessThan); + } + } + fn rebuildPortfolioRows(self: *App) void { self.portfolio_rows.clearRetainingCapacity(); @@ -1538,6 +1717,7 @@ const App = struct { } self.portfolio_summary = summary; + self.sortPortfolioAllocations(); self.rebuildPortfolioRows(); if (missing > 0) { @@ -1662,13 +1842,27 @@ const App = struct { buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s }; } } else { - for (0..@min(line.text.len, width)) |ci| { + // UTF-8 aware rendering: byte index and column index tracked separately + var col: usize = 0; + var bi: usize = 0; + while (bi < line.text.len and col < width) { var s = line.style; - // Apply alt_style for the gain/loss column range if (line.alt_style) |alt| { - if (ci >= line.alt_start and ci < line.alt_end) s = alt; + if (col >= line.alt_start and col < line.alt_end) s = alt; } - buf[row * width + ci] = .{ .char = .{ .grapheme = glyph(line.text[ci]) }, .style = s }; + const byte = line.text[bi]; + if (byte < 0x80) { + // ASCII: single byte, single column + buf[row * width + col] = .{ .char = .{ .grapheme = ascii_g[byte] }, .style = s }; + bi += 1; + } else { + // Multi-byte UTF-8: determine sequence length + const seq_len: usize = if (byte >= 0xF0) 4 else if (byte >= 0xE0) 3 else if (byte >= 0xC0) 2 else 1; + const end = @min(bi + seq_len, line.text.len); + buf[row * width + col] = .{ .char = .{ .grapheme = line.text[bi..end] }, .style = s }; + bi = end; + } + col += 1; } } } @@ -1759,8 +1953,29 @@ const App = struct { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Column header (4-char prefix to match arrow(2)+star(2) in data rows) - const hdr = try std.fmt.allocPrint(arena, " {s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{ - "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account", + // Active sort column gets a sort indicator within the column width + const sf = self.portfolio_sort_field; + const si = self.portfolio_sort_dir.indicator(); + // Build column labels with indicator embedded in padding + // Left-aligned cols: "Name▲ " Right-aligned cols: " ▼Price" + var sym_hdr_buf: [16]u8 = undefined; + var shr_hdr_buf: [16]u8 = undefined; + var avg_hdr_buf: [16]u8 = undefined; + var prc_hdr_buf: [16]u8 = undefined; + 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 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}", .{ + 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() }); @@ -1840,7 +2055,7 @@ const App = struct { } } - const text = try std.fmt.allocPrint(arena, "{s}{s}{s:<6} {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}" ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{ arrow, star, a.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, acct_col, }); @@ -1855,8 +2070,8 @@ const App = struct { .text = text, .style = base_style, .alt_style = gl_style, - .alt_start = 59, - .alt_end = 59 + 14, + .alt_start = gl_col_start, + .alt_end = gl_col_start + 14, }); } } @@ -1889,7 +2104,7 @@ const App = struct { const indicator = fmt.capitalGainsIndicator(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, " {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{ + 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}", .{ status_str, lot.shares, lot_price_str, "", "", lot_gl_str, "", lot_date_col, acct_col, }); const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle(); @@ -1898,8 +2113,8 @@ const App = struct { .text = text, .style = base_style, .alt_style = gl_col_style, - .alt_start = 59, - .alt_end = 59 + 14, + .alt_start = gl_col_start, + .alt_end = gl_col_start + 14, }); } }, @@ -1913,7 +2128,7 @@ const App = struct { break :blk @as([]const u8, "--"); } else "--"; const star2: []const u8 = if (is_active_sym) "* " else " "; - const text = try std.fmt.allocPrint(arena, " {s}{s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{ + 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}", .{ star2, row.symbol, "--", "--", ps, "--", "--", "watch", "", }); const row_style = if (is_cursor) th.selectStyle() else th.contentStyle(); @@ -2000,11 +2215,9 @@ const App = struct { }, .cash_row => { if (row.lot) |lot| { - var cash_amt_buf: [24]u8 = undefined; - const text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{ - row.symbol, // account name - fmt.fmtMoney(&cash_amt_buf, lot.shares), - }); + var cash_row_buf: [80]u8 = undefined; + const row_text = fmt.fmtCashRow(&cash_row_buf, row.symbol, lot.shares); + const text = try std.fmt.allocPrint(arena, " {s}", .{row_text}); const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle(); try lines.append(arena, .{ .text = text, .style = row_style5 }); } @@ -2992,11 +3205,13 @@ const App = struct { "Scroll to bottom", "Page down", "Page up", "Select next", "Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)", "This help", "Edit portfolio/watchlist", + "Reload portfolio from disk", "Toggle all calls (options)", "Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM", "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", "Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe", "Chart: prev timeframe", + "Sort: next column", "Sort: prev column", "Sort: reverse order", }; for (actions, 0..) |action, ai| {