options and cd display cleanup (TUI)

This commit is contained in:
Emil Lerch 2026-03-31 15:47:36 -07:00
parent 31dd551efe
commit 9de40e8219
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 52 additions and 27 deletions

View file

@ -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,

View file

@ -413,27 +413,23 @@ 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) {
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)) {
has_matching = true;
break;
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)) {
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,
@ -442,7 +438,6 @@ pub fn rebuildPortfolioRows(app: *App) void {
}
}
}
}
// CDs section (sorted by maturity date, earliest first; filtered by account)
if (pf.hasType(.cd)) {
@ -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 });
}
},