diff --git a/src/format.zig b/src/format.zig index 1b6b7e8..6628e2d 100644 --- a/src/format.zig +++ b/src/format.zig @@ -385,6 +385,15 @@ pub fn lotMaturitySortFn(_: void, a: Lot, b: Lot) bool { return ad < bd; } +/// Sort lots by maturity date (earliest first), then by symbol name. +/// Lots without maturity sort last. +pub fn lotMaturityThenSymbolSortFn(_: void, a: Lot, b: Lot) bool { + const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32); + const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32); + if (ad != bd) return ad < bd; + return std.mem.lessThan(u8, a.symbol, b.symbol); +} + /// 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/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 9d9db77..c2f7762 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -413,33 +413,28 @@ pub fn rebuildPortfolioRows(app: *App) void { } } - // Options section (filtered by account when filter is active) + // Options section (sorted by expiration date, then symbol; filtered by account) if (app.portfolio) |pf| { if (pf.hasType(.option)) { - var has_matching = false; - if (app.account_filter != null) { - for (pf.lots) |lot| { - if (lot.security_type == .option and matchesAccountFilter(app, lot.account)) { - has_matching = true; - break; - } + 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; } - } else { - has_matching = true; } - if (has_matching) { + if (option_lots.items.len > 0) { app.portfolio_rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Options", }) catch {}; - for (pf.lots) |lot| { - if (lot.security_type == .option and matchesAccountFilter(app, lot.account)) { - app.portfolio_rows.append(app.allocator, .{ - .kind = .option_row, - .symbol = lot.symbol, - .lot = lot, - }) catch continue; - } + std.mem.sort(zfin.Lot, option_lots.items, {}, fmt.lotMaturityThenSymbolSortFn); + for (option_lots.items) |lot| { + app.portfolio_rows.append(app.allocator, .{ + .kind = .option_row, + .symbol = lot.symbol, + .lot = lot, + }) catch continue; } } } @@ -1022,7 +1017,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width // 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", "Total Cost", "Account", + "Contract", "Qty", "Cost/Ctrct", "Premium", "Account", }); try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() }); } else if (std.mem.eql(u8, row.symbol, "Certificates of Deposit")) { @@ -1034,22 +1029,41 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width }, .option_row => { if (row.lot) |lot| { - // Options: symbol (description), qty (contracts), cost/contract, cost basis, account + // 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_cost = @abs(qty) * cost_per; + 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 total_buf: [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), - fmt.fmtMoneyAbs(&total_buf, total_cost), + prem_str, acct_col2, }); - const row_style2 = if (is_cursor) th.selectStyle() else th.contentStyle(); - try lines.append(arena, .{ .text = text, .style = row_style2 }); + 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(); + try lines.append(arena, .{ + .text = text, + .style = row_style2, + .alt_style = prem_style, + .alt_start = prem_col_start, + .alt_end = prem_col_start + 14, + }); } }, .cd_row => { @@ -1075,7 +1089,9 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width note_display, acct_col3, }); - const row_style3 = if (is_cursor) th.selectStyle() else th.contentStyle(); + 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(); try lines.append(arena, .{ .text = text, .style = row_style3 }); } },