From 1f9f90357f544f493988bc6e6254463a110c44c0 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 31 Mar 2026 17:02:32 -0700 Subject: [PATCH] create a view model for options and cds that is reused between cli/tui --- src/commands/portfolio.zig | 156 ++++++++++----------- src/format.zig | 11 ++ src/tui.zig | 18 +++ src/tui/portfolio_tab.zig | 127 ++++++----------- src/views/portfolio_sections.zig | 228 +++++++++++++++++++++++++++++++ 5 files changed, 368 insertions(+), 172 deletions(-) create mode 100644 src/views/portfolio_sections.zig diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 77440ac..5710855 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -2,6 +2,18 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; +const views = @import("../views/portfolio_sections.zig"); + +/// Map a semantic StyleIntent to CLI ANSI foreground color. +fn setIntentFg(out: *std.Io.Writer, color: bool, intent: fmt.StyleIntent) !void { + if (!color) return; + switch (intent) { + .normal => try cli.reset(out, color), + .muted => try cli.setFg(out, color, cli.CLR_MUTED), + .positive => try cli.setFg(out, color, cli.CLR_POSITIVE), + .negative => try cli.setFg(out, color, cli.CLR_NEGATIVE), + } +} pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void { // Load portfolio from SRF file @@ -364,101 +376,77 @@ pub fn display( // Options section if (portfolio.hasType(.option)) { - try out.print("\n", .{}); - try cli.setBold(out, color); - try out.print(" Options\n", .{}); - try cli.reset(out, color); - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" {s:<30} {s:>6} {s:>12} {s:>14} {s}\n", .{ - "Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account", - }); - try out.print(" {s:->30} {s:->6} {s:->12} {s:->14} {s:->10}\n", .{ - "", "", "", "", "", - }); - try cli.reset(out, color); + var prepared_opts = try views.Options.init(allocator, portfolio.lots, null); + defer prepared_opts.deinit(); + if (prepared_opts.items.len > 0) { + try out.print("\n", .{}); + try cli.setBold(out, color); + try out.print(" Options\n", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(views.OptionsLayout.header ++ "\n", views.OptionsLayout.header_labels); + try out.print(views.OptionsLayout.separator ++ "\n", views.OptionsLayout.separator_fills); + try cli.reset(out, color); - var opt_total_cost: f64 = 0; - for (portfolio.lots) |lot| { - if (lot.security_type != .option) continue; - const qty = lot.shares; - const cost_per = lot.open_price; - const total_cost_opt = @abs(qty) * cost_per; - opt_total_cost += total_cost_opt; - var cost_per_buf: [24]u8 = undefined; - var total_cost_buf: [24]u8 = undefined; - const acct: []const u8 = lot.account orelse ""; - try out.print(" {s:<30} {d:>6.0} {s:>12} {s:>14} {s}\n", .{ - lot.symbol, - qty, - fmt.fmtMoneyAbs(&cost_per_buf, cost_per), - fmt.fmtMoneyAbs(&total_cost_buf, total_cost_opt), - acct, + var opt_total_premium: f64 = 0; + for (prepared_opts.items) |po| { + opt_total_premium += po.premium; + const text = po.columns[0].text; + const prem_start = po.premium_col_start; + const prem_end = @min(prem_start + views.OptionsLayout.premium_w, text.len); + // Pre-premium portion + try setIntentFg(out, color, po.row_style); + try out.print("{s}", .{text[0..prem_start]}); + // Premium column + try setIntentFg(out, color, po.premium_style); + try out.print("{s}", .{text[prem_start..prem_end]}); + // Post-premium portion (account) + try setIntentFg(out, color, po.row_style); + if (prem_end < text.len) try out.print("{s}", .{text[prem_end..]}); + try cli.reset(out, color); + try out.print("\n", .{}); + } + // Options total + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" }); + try cli.reset(out, color); + var opt_total_buf: [24]u8 = undefined; + try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{ + "", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_premium), }); } - // Options total - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" }); - try cli.reset(out, color); - var opt_total_buf: [24]u8 = undefined; - try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{ - "", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_cost), - }); } // CDs section if (portfolio.hasType(.cd)) { - try out.print("\n", .{}); - try cli.setBold(out, color); - try out.print(" Certificates of Deposit\n", .{}); - try cli.reset(out, color); - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{ - "CUSIP", "Face Value", "Rate", "Maturity", "Description", - }); - try out.print(" {s:->12} {s:->14} {s:->7} {s:->10} {s:->30}\n", .{ - "", "", "", "", "", - }); - try cli.reset(out, color); + var prepared_cds = try views.CDs.init(allocator, portfolio.lots, null); + defer prepared_cds.deinit(); + if (prepared_cds.items.len > 0) { + try out.print("\n", .{}); + try cli.setBold(out, color); + try out.print(" Certificates of Deposit\n", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(views.CDsLayout.header ++ "\n", views.CDsLayout.header_labels); + try out.print(views.CDsLayout.separator ++ "\n", views.CDsLayout.separator_fills); + try cli.reset(out, color); - // Collect and sort CDs by maturity date (earliest first) - var cd_lots: std.ArrayList(zfin.Lot) = .empty; - defer cd_lots.deinit(allocator); - for (portfolio.lots) |lot| { - if (lot.security_type == .cd) { - try cd_lots.append(allocator, lot); + var cd_section_total: f64 = 0; + for (prepared_cds.items) |pc| { + cd_section_total += pc.lot.shares; + try setIntentFg(out, color, pc.row_style); + try out.print("{s}\n", .{pc.text}); + try cli.reset(out, color); } - } - std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn); - - var cd_section_total: f64 = 0; - for (cd_lots.items) |lot| { - cd_section_total += lot.shares; - var face_buf: [24]u8 = undefined; - var mat_buf: [10]u8 = undefined; - const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--"; - var rate_buf: [10]u8 = undefined; - const rate_str: []const u8 = if (lot.rate) |r| - std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--" - else - "--"; - const note_str: []const u8 = lot.note orelse ""; - const note_display = if (note_str.len > 50) note_str[0..50] else note_str; - try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{ - lot.symbol, - fmt.fmtMoneyAbs(&face_buf, lot.shares), - rate_str, - mat_str, - note_display, + // CD total + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:->12} {s:->14}\n", .{ "", "" }); + try cli.reset(out, color); + var cd_total_buf: [24]u8 = undefined; + try out.print(" {s:>12} {s:>14}\n", .{ + "TOTAL", fmt.fmtMoneyAbs(&cd_total_buf, cd_section_total), }); } - // CD total - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" {s:->12} {s:->14}\n", .{ "", "" }); - try cli.reset(out, color); - var cd_total_buf: [24]u8 = undefined; - try out.print(" {s:>12} {s:>14}\n", .{ - "TOTAL", fmt.fmtMoneyAbs(&cd_total_buf, cd_section_total), - }); } // Cash section diff --git a/src/format.zig b/src/format.zig index 6628e2d..d8cdd37 100644 --- a/src/format.zig +++ b/src/format.zig @@ -394,6 +394,17 @@ pub fn lotMaturityThenSymbolSortFn(_: void, a: Lot, b: Lot) bool { return std.mem.lessThan(u8, a.symbol, b.symbol); } +// ── Shared style intent ────────────────────────────────────── + +/// Semantic style intent — renderers map this to platform-specific styles. +/// Used by view models (e.g. views/portfolio_sections.zig) and renderers. +pub const StyleIntent = enum { + normal, // default text + muted, // dim/secondary (expired items) + positive, // green (gains, premium received) + negative, // red (losses, premium paid) +}; + /// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket. pub const DripSummary = struct { lot_count: usize = 0, diff --git a/src/tui.zig b/src/tui.zig index 961d921..e8e0389 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -2,6 +2,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("root.zig"); const fmt = @import("format.zig"); +const views = @import("views/portfolio_sections.zig"); const cli = @import("commands/common.zig"); const keybinds = @import("tui/keybinds.zig"); const theme_mod = @import("tui/theme.zig"); @@ -181,6 +182,13 @@ pub const PortfolioRow = struct { drip_avg_cost: f64 = 0, drip_date_first: ?zfin.Date = null, drip_date_last: ?zfin.Date = null, + /// Pre-formatted text from view model (options and CDs) + prepared_text: ?[]const u8 = null, + /// Semantic styles from view model + row_style: fmt.StyleIntent = .normal, + premium_style: fmt.StyleIntent = .normal, + /// Column offset for premium alt-style coloring (options only) + premium_col_start: usize = 0, const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary }; }; @@ -289,6 +297,8 @@ pub const App = struct { cash_expanded: bool = false, // whether cash section is expanded to show per-account illiquid_expanded: bool = false, // whether illiquid section is expanded to show per-asset portfolio_rows: std.ArrayList(PortfolioRow) = .empty, + prepared_options: ?views.Options = null, + prepared_cds: ?views.CDs = null, 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 @@ -1318,6 +1328,13 @@ pub const App = struct { self.portfolio_summary = null; } + pub fn freePreparedSections(self: *App) void { + if (self.prepared_options) |*opts| opts.deinit(); + self.prepared_options = null; + if (self.prepared_cds) |*cds| cds.deinit(); + self.prepared_cds = null; + } + fn deinitData(self: *App) void { self.freeCandles(); self.freeDividends(); @@ -1325,6 +1342,7 @@ pub const App = struct { self.freeOptions(); self.freeEtfProfile(); self.freePortfolioSummary(); + self.freePreparedSections(); self.portfolio_rows.deinit(self.allocator); self.options_rows.deinit(self.allocator); self.account_list.deinit(self.allocator); diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index c2f7762..9f0b85f 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -2,6 +2,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); +const views = @import("../views/portfolio_sections.zig"); const cli = @import("../commands/common.zig"); const theme_mod = @import("theme.zig"); const tui = @import("../tui.zig"); @@ -32,6 +33,16 @@ pub const col_end_date: usize = col_end_weight + 14; // Gain/loss column start position (used for alt-style coloring) const gl_col_start: usize = col_end_market_value; +/// Map a semantic StyleIntent to a platform-specific vaxis style. +fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style { + return switch (intent) { + .normal => th.contentStyle(), + .muted => th.mutedStyle(), + .positive => th.positiveStyle(), + .negative => th.negativeStyle(), + }; +} + // ── Data loading ────────────────────────────────────────────── /// Load portfolio data: prices, summary, candle map, and historical snapshots. @@ -268,6 +279,7 @@ pub fn sortPortfolioAllocations(app: *App) void { pub fn rebuildPortfolioRows(app: *App) void { app.portfolio_rows.clearRetainingCapacity(); + app.freePreparedSections(); if (app.portfolio_summary) |s| { for (s.allocations, 0..) |a, i| { @@ -415,50 +427,42 @@ pub fn rebuildPortfolioRows(app: *App) void { // Options section (sorted by expiration date, then symbol; filtered by account) if (app.portfolio) |pf| { - if (pf.hasType(.option)) { - var option_lots: std.ArrayList(zfin.Lot) = .empty; - defer option_lots.deinit(app.allocator); - for (pf.lots) |lot| { - if (lot.security_type == .option and matchesAccountFilter(app, lot.account)) { - option_lots.append(app.allocator, lot) catch continue; - } - } - if (option_lots.items.len > 0) { + app.prepared_options = views.Options.init(app.allocator, pf.lots, app.account_filter) catch null; + if (app.prepared_options) |opts| { + if (opts.items.len > 0) { app.portfolio_rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Options", }) catch {}; - std.mem.sort(zfin.Lot, option_lots.items, {}, fmt.lotMaturityThenSymbolSortFn); - for (option_lots.items) |lot| { + for (opts.items) |po| { app.portfolio_rows.append(app.allocator, .{ .kind = .option_row, - .symbol = lot.symbol, - .lot = lot, + .symbol = po.lot.symbol, + .lot = po.lot, + .prepared_text = po.columns[0].text, + .row_style = po.row_style, + .premium_style = po.premium_style, + .premium_col_start = po.premium_col_start, }) catch continue; } } } // CDs section (sorted by maturity date, earliest first; filtered by account) - if (pf.hasType(.cd)) { - var cd_lots: std.ArrayList(zfin.Lot) = .empty; - defer cd_lots.deinit(app.allocator); - for (pf.lots) |lot| { - if (lot.security_type == .cd and matchesAccountFilter(app, lot.account)) { - cd_lots.append(app.allocator, lot) catch continue; - } - } - if (cd_lots.items.len > 0) { + app.prepared_cds = views.CDs.init(app.allocator, pf.lots, app.account_filter) catch null; + if (app.prepared_cds) |cds| { + if (cds.items.len > 0) { app.portfolio_rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Certificates of Deposit", }) catch {}; - std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn); - for (cd_lots.items) |lot| { + for (cds.items) |pc| { app.portfolio_rows.append(app.allocator, .{ .kind = .cd_row, - .symbol = lot.symbol, - .lot = lot, + .symbol = pc.lot.symbol, + .lot = pc.lot, + .prepared_text = pc.text, + .row_style = pc.row_style, }) catch continue; } } @@ -1016,82 +1020,29 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width try lines.append(arena, .{ .text = hdr_text, .style = hdr_style }); // Add column headers for each section type if (std.mem.eql(u8, row.symbol, "Options")) { - const col_hdr = try std.fmt.allocPrint(arena, " {s:<30} {s:>6} {s:>12} {s:>14} {s}", .{ - "Contract", "Qty", "Cost/Ctrct", "Premium", "Account", - }); + const col_hdr = try std.fmt.allocPrint(arena, views.OptionsLayout.header, views.OptionsLayout.header_labels); try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() }); } else if (std.mem.eql(u8, row.symbol, "Certificates of Deposit")) { - const col_hdr = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{ - "CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account", - }); + const col_hdr = try std.fmt.allocPrint(arena, views.CDsLayout.header, views.CDsLayout.header_labels); try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() }); } }, .option_row => { - if (row.lot) |lot| { - // Options: symbol (description), qty (contracts), cost/contract, premium (+/-), account - const qty = lot.shares; // negative = short - const cost_per = lot.open_price; // per-contract cost - const total_premium = @abs(qty) * cost_per * lot.multiplier; - // Short = received premium (+), Long = paid premium (-) - const received = qty < 0; - var cost_buf3: [24]u8 = undefined; - var prem_val_buf: [24]u8 = undefined; - const prem_money = fmt.fmtMoneyAbs(&prem_val_buf, total_premium); - var prem_buf: [20]u8 = undefined; - const prem_str = if (received) - std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?" - else - std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?"; - const acct_col2: []const u8 = lot.account orelse ""; - // Column layout: 4 + 30 + 1 + 6 + 1 + 12 + 1 = 55 (premium start) - const prem_col_start: usize = 55; - const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{ - lot.symbol, - qty, - fmt.fmtMoneyAbs(&cost_buf3, cost_per), - prem_str, - acct_col2, - }); - const today = fmt.todayDate(); - const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false; - const row_style2 = if (is_cursor) th.selectStyle() else if (is_expired) th.mutedStyle() else th.contentStyle(); - const prem_style = if (is_cursor) th.selectStyle() else if (is_expired) th.mutedStyle() else if (received) th.positiveStyle() else th.negativeStyle(); + if (row.prepared_text) |text| { + const row_style2 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style); + const prem_style = if (is_cursor) th.selectStyle() else mapIntent(th, row.premium_style); try lines.append(arena, .{ .text = text, .style = row_style2, .alt_style = prem_style, - .alt_start = prem_col_start, - .alt_end = prem_col_start + 14, + .alt_start = row.premium_col_start, + .alt_end = row.premium_col_start + 14, }); } }, .cd_row => { - if (row.lot) |lot| { - // CDs: symbol (CUSIP), face value, rate%, maturity date, note, account - var face_buf: [24]u8 = undefined; - var mat_buf: [10]u8 = undefined; - const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--"; - var rate_str_buf: [10]u8 = undefined; - const rate_str: []const u8 = if (lot.rate) |r| - std.fmt.bufPrint(&rate_str_buf, "{d:.2}%", .{r}) catch "--" - else - "--"; - const note_str: []const u8 = lot.note orelse ""; - // Truncate note to 40 chars for display - const note_display = if (note_str.len > 40) note_str[0..40] else note_str; - const acct_col3: []const u8 = lot.account orelse ""; - const text = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{ - lot.symbol, - fmt.fmtMoneyAbs(&face_buf, lot.shares), - rate_str, - mat_str, - note_display, - acct_col3, - }); - const today = fmt.todayDate(); - const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false; - const row_style3 = if (is_cursor) th.selectStyle() else if (is_expired) th.mutedStyle() else th.contentStyle(); + if (row.prepared_text) |text| { + const row_style3 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style); try lines.append(arena, .{ .text = text, .style = row_style3 }); } }, diff --git a/src/views/portfolio_sections.zig b/src/views/portfolio_sections.zig new file mode 100644 index 0000000..0d40911 --- /dev/null +++ b/src/views/portfolio_sections.zig @@ -0,0 +1,228 @@ +//! View models for portfolio sections (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 +//! to platform-specific styles and emit pre-formatted text. + +const std = @import("std"); +const Lot = @import("../models/portfolio.zig").Lot; +const Date = @import("../models/date.zig").Date; +const fmt = @import("../format.zig"); + +// ── Options ─────────────────────────────────────────────────── + +/// Column layout for the Options section. +/// All format strings are derived from the width constants. +pub const OptionsLayout = struct { + const cp = std.fmt.comptimePrint; + pub const prefix = " "; + pub const symbol_w = 30; + pub const qty_w = 6; + pub const cost_w = 12; + pub const premium_w = 14; + pub const account_w = 10; + + pub const premium_col_start: usize = prefix.len + symbol_w + 1 + qty_w + 1 + cost_w + 1; + + pub const header = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:>{d}}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}"; + pub const header_labels = .{ "Contract", "Qty", "Cost/Ctrct", "Premium", "Account" }; + + pub const separator = prefix ++ cp("{{s:->{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:->{d}}}", .{qty_w}) ++ " " ++ cp("{{s:->{d}}}", .{cost_w}) ++ " " ++ cp("{{s:->{d}}}", .{premium_w}) ++ " " ++ cp("{{s:->{d}}}", .{account_w}); + pub const separator_fills = .{ "", "", "", "", "" }; + + pub const data_row = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{d:>{d}.0}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}"; +}; + +/// A styled text span for multi-style row rendering. +pub const StyledSpan = struct { + text: []const u8, + style: fmt.StyleIntent, +}; + +/// A single option row with pre-computed display values. +pub const Option = struct { + lot: Lot, + premium: f64, + received: bool, + is_expired: bool, + row_style: fmt.StyleIntent, + premium_style: fmt.StyleIntent, + columns: [2]StyledSpan, + premium_col_start: usize, +}; + +/// Collection of prepared option rows. Owns all allocated text. +pub const Options = struct { + items: []const Option, + allocator: std.mem.Allocator, + + /// Build sorted, filtered, display-ready option rows from raw lots. + pub fn init(allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !Options { + const today = fmt.todayDate(); + var list: std.ArrayList(Option) = .empty; + errdefer { + for (list.items) |opt| allocator.free(opt.columns[0].text); + list.deinit(allocator); + } + + var tmp: std.ArrayList(Lot) = .empty; + defer tmp.deinit(allocator); + for (lots) |lot| { + if (lot.security_type != .option) continue; + if (account_filter) |af| { + const la = lot.account orelse ""; + if (!std.mem.eql(u8, la, af)) continue; + } + try tmp.append(allocator, lot); + } + std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn); + + for (tmp.items) |lot| { + const qty = lot.shares; + const cost_per = lot.open_price; + const premium = @abs(qty) * cost_per * lot.multiplier; + const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false; + const received = qty < 0; + + const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal; + const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative; + + var cost_buf: [24]u8 = undefined; + var prem_val_buf: [24]u8 = undefined; + const prem_money = fmt.fmtMoneyAbs(&prem_val_buf, premium); + var prem_buf: [20]u8 = undefined; + const prem_str = if (received) + std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?" + else + std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?"; + const acct = lot.account orelse ""; + + const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{ + lot.symbol, + qty, + fmt.fmtMoneyAbs(&cost_buf, cost_per), + prem_str, + acct, + }); + + try list.append(allocator, .{ + .lot = lot, + .premium = premium, + .received = received, + .is_expired = is_expired, + .row_style = row_style, + .premium_style = premium_style, + .columns = .{ + .{ .text = text, .style = row_style }, + .{ .text = prem_str, .style = premium_style }, + }, + .premium_col_start = OptionsLayout.premium_col_start, + }); + } + return .{ .items = try list.toOwnedSlice(allocator), .allocator = allocator }; + } + + pub fn deinit(self: *Options) void { + for (self.items) |opt| self.allocator.free(opt.columns[0].text); + self.allocator.free(self.items); + self.items = &.{}; + } +}; + +// ── CDs ─────────────────────────────────────────────────────── + +/// Column layout for the Certificates of Deposit section. +pub const CDsLayout = struct { + const cp = std.fmt.comptimePrint; + pub const prefix = " "; + pub const cusip_w = 12; + pub const face_w = 14; + pub const rate_w = 7; + pub const maturity_w = 10; + pub const desc_w = 40; + pub const account_w = 10; + + pub const header = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ " {s} {s}"; + pub const header_labels = .{ "CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account" }; + + pub const separator = prefix ++ cp("{{s:->{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:->{d}}}", .{face_w}) ++ " " ++ cp("{{s:->{d}}}", .{rate_w}) ++ " " ++ cp("{{s:->{d}}}", .{maturity_w}) ++ " " ++ cp("{{s:->{d}}}", .{desc_w}) ++ " " ++ cp("{{s:->{d}}}", .{account_w}); + pub const separator_fills = .{ "", "", "", "", "", "" }; + + pub const data_row = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ " {s} {s}"; +}; + +/// A single CD row with pre-computed display values. +pub const CD = struct { + lot: Lot, + is_expired: bool, + row_style: fmt.StyleIntent, + text: []const u8, +}; + +/// Collection of prepared CD rows. Owns all allocated text. +pub const CDs = struct { + items: []const CD, + allocator: std.mem.Allocator, + + /// Build sorted, filtered, display-ready CD rows from raw lots. + pub fn init(allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !CDs { + const today = fmt.todayDate(); + var list: std.ArrayList(CD) = .empty; + errdefer { + for (list.items) |cd| allocator.free(cd.text); + list.deinit(allocator); + } + + var tmp: std.ArrayList(Lot) = .empty; + defer tmp.deinit(allocator); + for (lots) |lot| { + if (lot.security_type != .cd) continue; + if (account_filter) |af| { + const la = lot.account orelse ""; + if (!std.mem.eql(u8, la, af)) continue; + } + try tmp.append(allocator, lot); + } + std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn); + + for (tmp.items) |lot| { + const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false; + const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal; + + var face_buf: [24]u8 = undefined; + var mat_buf: [10]u8 = undefined; + const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--"; + var rate_buf: [10]u8 = undefined; + const rate_str: []const u8 = if (lot.rate) |r| + std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--" + else + "--"; + const note_str: []const u8 = lot.note orelse ""; + const note_display = if (note_str.len > 40) note_str[0..40] else note_str; + const acct = lot.account orelse ""; + + const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{ + lot.symbol, + fmt.fmtMoneyAbs(&face_buf, lot.shares), + rate_str, + mat_str, + note_display, + acct, + }); + + try list.append(allocator, .{ + .lot = lot, + .is_expired = is_expired, + .row_style = row_style, + .text = text, + }); + } + return .{ .items = try list.toOwnedSlice(allocator), .allocator = allocator }; + } + + pub fn deinit(self: *CDs) void { + for (self.items) |cd| self.allocator.free(cd.text); + self.allocator.free(self.items); + self.items = &.{}; + } +};