diff --git a/src/cache/store.zig b/src/cache/store.zig index ae6c23d..9f614d7 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -818,6 +818,16 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co try writer.writeAll("#!srfv1\n"); for (lots) |lot| { var od_buf: [10]u8 = undefined; + // Write security_type if not stock (stock is the default) + if (lot.lot_type != .stock) { + const type_str: []const u8 = switch (lot.lot_type) { + .option => "option", + .cd => "cd", + .cash => "cash", + .stock => unreachable, + }; + try writer.print("security_type::{s},", .{type_str}); + } try writer.print("symbol::{s},shares:num:{d},open_date::{s},open_price:num:{d}", .{ lot.symbol, lot.shares, lot.open_date.format(&od_buf), lot.open_price, }); @@ -834,6 +844,13 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co if (lot.account) |a| { try writer.print(",account::{s}", .{a}); } + if (lot.maturity_date) |md| { + var md_buf: [10]u8 = undefined; + try writer.print(",maturity_date::{s}", .{md.format(&md_buf)}); + } + if (lot.rate) |r| { + try writer.print(",rate:num:{d}", .{r}); + } try writer.writeAll("\n"); } @@ -842,6 +859,7 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co /// Deserialize a portfolio from SRF data. Caller owns the returned Portfolio. pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Portfolio { + const LotType = @import("../models/portfolio.zig").LotType; var lots: std.ArrayList(Lot) = .empty; errdefer { for (lots.items) |lot| { @@ -866,6 +884,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por var sym_raw: ?[]const u8 = null; var note_raw: ?[]const u8 = null; var account_raw: ?[]const u8 = null; + var sec_type_raw: ?[]const u8 = null; for (record.fields) |field| { if (std.mem.eql(u8, field.key, "symbol")) { @@ -890,10 +909,34 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por if (field.value) |v| note_raw = switch (v) { .string => |s| s, else => null }; } else if (std.mem.eql(u8, field.key, "account")) { if (field.value) |v| account_raw = switch (v) { .string => |s| s, else => null }; + } else if (std.mem.eql(u8, field.key, "security_type")) { + if (field.value) |v| sec_type_raw = switch (v) { .string => |s| s, else => null }; + } else if (std.mem.eql(u8, field.key, "maturity_date")) { + if (field.value) |v| { + const str = switch (v) { .string => |s| s, else => continue }; + lot.maturity_date = Date.parse(str) catch null; + } + } else if (std.mem.eql(u8, field.key, "rate")) { + if (field.value) |v| { + const r = Store.numVal(v); + if (r > 0) lot.rate = r; + } } } - if (sym_raw) |s| { + // Determine lot type + if (sec_type_raw) |st| { + lot.lot_type = LotType.fromString(st); + } + + // Cash lots don't require a symbol -- generate a placeholder + if (lot.lot_type == .cash) { + if (sym_raw == null) { + lot.symbol = try allocator.dupe(u8, "CASH"); + } else { + lot.symbol = try allocator.dupe(u8, sym_raw.?); + } + } else if (sym_raw) |s| { lot.symbol = try allocator.dupe(u8, s); } else continue; diff --git a/src/cli/main.zig b/src/cli/main.zig index 3bd9fc3..e46e2b4 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -991,17 +991,19 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: [] return; } - // Get positions + // Get stock/ETF positions (excludes options, CDs, cash) const positions = try portfolio.positions(allocator); defer allocator.free(positions); - // Get unique symbols and fetch current prices - const syms = try portfolio.symbols(allocator); + // Get unique stock/ETF symbols and fetch current prices + const syms = try portfolio.stockSymbols(allocator); defer allocator.free(syms); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); + var fail_count: usize = 0; + if (config.twelvedata_key) |td_key| { var td = zfin.TwelveData.init(allocator, td_key); defer td.deinit(); @@ -1018,13 +1020,23 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: [] defer q.deinit(); const price = q.close(); if (price > 0) try prices.put(sym, price); - } else |_| {} - } else |_| {} + } else |_| { + fail_count += 1; + } + } else |_| { + fail_count += 1; + } } } else { try stderr_print("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n"); } + if (fail_count > 0) { + var warn_msg_buf: [128]u8 = undefined; + const warn_msg = std.fmt.bufPrint(&warn_msg_buf, "Warning: {d} securities failed to load prices\n", .{fail_count}) catch "Warning: some securities failed\n"; + try stderr_print(warn_msg); + } + // Compute summary var summary = zfin.risk.portfolioSummary(allocator, positions, prices) catch { try stderr_print("Error computing portfolio summary.\n"); @@ -1032,6 +1044,23 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: [] }; defer summary.deinit(allocator); + // Include non-stock assets in the grand total + const cash_total = portfolio.totalCash(); + const cd_total = portfolio.totalCdFaceValue(); + const opt_total = portfolio.totalOptionCost(); + const non_stock = cash_total + cd_total + opt_total; + summary.total_value += non_stock; + summary.total_cost += non_stock; + if (summary.total_cost > 0) { + summary.unrealized_return = summary.unrealized_pnl / summary.total_cost; + } + // Reweight allocations against grand total + if (summary.total_value > 0) { + for (summary.allocations) |*a| { + a.weight = a.market_value / summary.total_value; + } + } + var buf: [32768]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; @@ -1059,10 +1088,11 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: [] try out.print("\n", .{}); } - // Lot counts + // Lot counts (stocks/ETFs only) var open_lots: u32 = 0; var closed_lots: u32 = 0; for (portfolio.lots) |lot| { + if (lot.lot_type != .stock) continue; if (lot.isOpen()) open_lots += 1 else closed_lots += 1; } try setFg(out, color, CLR_MUTED); @@ -1082,11 +1112,11 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: [] // Position rows with lot detail for (summary.allocations) |a| { - // Count lots for this symbol + // Count stock lots for this symbol var lots_for_sym: std.ArrayList(zfin.Lot) = .empty; defer lots_for_sym.deinit(allocator); for (portfolio.lots) |lot| { - if (std.mem.eql(u8, lot.symbol, a.symbol)) { + if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) { try lots_for_sym.append(allocator, lot); } } @@ -1202,6 +1232,144 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: [] try reset(out, color); } + // Options section + if (portfolio.hasType(.option)) { + try out.print("\n", .{}); + try setBold(out, color); + try out.print(" Options\n", .{}); + try reset(out, color); + try setFg(out, color, 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 reset(out, color); + + var opt_total_cost: f64 = 0; + for (portfolio.lots) |lot| { + if (lot.lot_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.fmtMoney2(&cost_per_buf, cost_per), + fmt.fmtMoney(&total_cost_buf, total_cost_opt), + acct, + }); + } + // Options total + try setFg(out, color, CLR_MUTED); + try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" }); + try reset(out, color); + var opt_total_buf: [24]u8 = undefined; + try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{ + "", "", "TOTAL", fmt.fmtMoney(&opt_total_buf, opt_total_cost), + }); + } + + // CDs section + if (portfolio.hasType(.cd)) { + try out.print("\n", .{}); + try setBold(out, color); + try out.print(" Certificates of Deposit\n", .{}); + try reset(out, color); + try setFg(out, color, 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 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.lot_type == .cd) { + try cd_lots.append(allocator, lot); + } + } + std.mem.sort(zfin.Lot, cd_lots.items, {}, struct { + fn f(ctx: void, a: zfin.Lot, b: zfin.Lot) bool { + _ = ctx; + 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); + return ad < bd; + } + }.f); + + 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.fmtMoney(&face_buf, lot.shares), + rate_str, + mat_str, + note_display, + }); + } + // CD total + try setFg(out, color, CLR_MUTED); + try out.print(" {s:->12} {s:->14}\n", .{ "", "" }); + try reset(out, color); + var cd_total_buf: [24]u8 = undefined; + try out.print(" {s:>12} {s:>14}\n", .{ + "TOTAL", fmt.fmtMoney(&cd_total_buf, cd_section_total), + }); + } + + // Cash section + if (portfolio.hasType(.cash)) { + try out.print("\n", .{}); + try setBold(out, color); + 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", .{ "", "" }); + 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), + }); + } + // Cash total + try setFg(out, color, CLR_MUTED); + try out.print(" {s:->20} {s:->14}\n", .{ "", "" }); + try reset(out, color); + var cash_total_buf: [24]u8 = undefined; + try setBold(out, color); + try out.print(" {s:>20} {s:>14}\n", .{ + "TOTAL", fmt.fmtMoney(&cash_total_buf, portfolio.totalCash()), + }); + try reset(out, color); + } + // Watchlist if (watchlist_path) |wl_path| { const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null; diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index e42d8e7..8ac1e24 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -1,6 +1,30 @@ const std = @import("std"); const Date = @import("date.zig").Date; +/// Type of holding in a portfolio lot. +pub const LotType = enum { + stock, // stocks and ETFs (default) + option, // option contracts + cd, // certificates of deposit + cash, // cash/money market + + pub fn label(self: LotType) []const u8 { + return switch (self) { + .stock => "Stock", + .option => "Option", + .cd => "CD", + .cash => "Cash", + }; + } + + pub fn fromString(s: []const u8) LotType { + if (std.mem.eql(u8, s, "option")) return .option; + if (std.mem.eql(u8, s, "cd")) return .cd; + if (std.mem.eql(u8, s, "cash")) return .cash; + return .stock; + } +}; + /// A single lot in a portfolio -- one purchase/sale event. /// Open lots have no close_date/close_price. /// Closed lots have both. @@ -15,6 +39,12 @@ pub const Lot = struct { note: ?[]const u8 = null, /// Optional account identifier (e.g. "Roth IRA", "Brokerage") account: ?[]const u8 = null, + /// Type of holding (stock, option, cd, cash) + lot_type: LotType = .stock, + /// Maturity date (for CDs) + maturity_date: ?Date = null, + /// Interest rate (for CDs, as percentage e.g. 3.8 = 3.8%) + rate: ?f64 = null, pub fn isOpen(self: Lot) bool { return self.close_date == null; @@ -75,7 +105,7 @@ pub const Portfolio = struct { self.allocator.free(self.lots); } - /// Get all unique symbols in the portfolio. + /// Get all unique symbols in the portfolio (all types). pub fn symbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 { var seen = std.StringHashMap(void).init(allocator); defer seen.deinit(); @@ -94,6 +124,27 @@ pub const Portfolio = struct { return result.toOwnedSlice(allocator); } + /// Get unique symbols for stock/ETF lots only (skips options, CDs, cash). + pub fn stockSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 { + var seen = std.StringHashMap(void).init(allocator); + defer seen.deinit(); + + for (self.lots) |lot| { + if (lot.lot_type == .stock) { + try seen.put(lot.symbol, {}); + } + } + + var result = std.ArrayList([]const u8).empty; + errdefer result.deinit(allocator); + + var iter = seen.keyIterator(); + while (iter.next()) |key| { + try result.append(allocator, key.*); + } + return result.toOwnedSlice(allocator); + } + /// Get all lots for a given symbol. pub fn lotsForSymbol(self: Portfolio, allocator: std.mem.Allocator, symbol: []const u8) ![]Lot { var result = std.ArrayList(Lot).empty; @@ -107,12 +158,26 @@ pub const Portfolio = struct { return result.toOwnedSlice(allocator); } - /// Aggregate lots into positions by symbol. + /// Get all lots of a given security type (allocated copy). + pub fn lotsOfTypeAlloc(self: Portfolio, allocator: std.mem.Allocator, sec_type: LotType) ![]Lot { + var result = std.ArrayList(Lot).empty; + errdefer result.deinit(allocator); + + for (self.lots) |lot| { + if (lot.lot_type == sec_type) { + try result.append(allocator, lot); + } + } + return result.toOwnedSlice(allocator); + } + + /// Aggregate stock/ETF lots into positions by symbol (skips options, CDs, cash). pub fn positions(self: Portfolio, allocator: std.mem.Allocator) ![]Position { var map = std.StringHashMap(Position).init(allocator); defer map.deinit(); for (self.lots) |lot| { + if (lot.lot_type != .stock) continue; const entry = try map.getOrPut(lot.symbol); if (!entry.found_existing) { entry.value_ptr.* = .{ @@ -153,23 +218,63 @@ pub const Portfolio = struct { return result.toOwnedSlice(allocator); } - /// Total cost basis of all open lots. + /// Total cost basis of all open stock lots. pub fn totalCostBasis(self: Portfolio) f64 { var total: f64 = 0; for (self.lots) |lot| { - if (lot.isOpen()) total += lot.costBasis(); + if (lot.isOpen() and lot.lot_type == .stock) total += lot.costBasis(); } return total; } - /// Total realized P&L from all closed lots. + /// Total realized P&L from all closed stock lots. pub fn totalRealizedPnl(self: Portfolio) f64 { var total: f64 = 0; for (self.lots) |lot| { - if (lot.realizedPnl()) |pnl| total += pnl; + if (lot.lot_type == .stock) { + if (lot.realizedPnl()) |pnl| total += pnl; + } } return total; } + + /// Total cash across all accounts. + pub fn totalCash(self: Portfolio) f64 { + var total: f64 = 0; + for (self.lots) |lot| { + if (lot.lot_type == .cash) total += lot.shares; + } + return total; + } + + /// Total CD face value across all accounts. + pub fn totalCdFaceValue(self: Portfolio) f64 { + var total: f64 = 0; + for (self.lots) |lot| { + if (lot.lot_type == .cd) total += lot.shares; + } + return total; + } + + /// Total option cost basis (absolute value of shares * open_price). + pub fn totalOptionCost(self: Portfolio) f64 { + var total: f64 = 0; + for (self.lots) |lot| { + if (lot.lot_type == .option) { + // shares can be negative (short), open_price is per-contract cost + total += @abs(lot.shares) * lot.open_price; + } + } + return total; + } + + /// Check if portfolio has any lots of a given type. + pub fn hasType(self: Portfolio, sec_type: LotType) bool { + for (self.lots) |lot| { + if (lot.lot_type == sec_type) return true; + } + return false; + } }; test "lot basics" { diff --git a/src/root.zig b/src/root.zig index 8b83fc7..a2b0b72 100644 --- a/src/root.zig +++ b/src/root.zig @@ -21,6 +21,7 @@ pub const SectorWeight = @import("models/etf_profile.zig").SectorWeight; pub const TickerInfo = @import("models/ticker_info.zig").TickerInfo; pub const SecurityType = @import("models/ticker_info.zig").SecurityType; pub const Lot = @import("models/portfolio.zig").Lot; +pub const LotType = @import("models/portfolio.zig").LotType; pub const Position = @import("models/portfolio.zig").Position; pub const Portfolio = @import("models/portfolio.zig").Portfolio; pub const Quote = @import("models/quote.zig").Quote; diff --git a/src/tui/main.zig b/src/tui/main.zig index 2837e2d..1dcfd50 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -55,7 +55,7 @@ const InputMode = enum { help, }; -/// A row in the portfolio view -- either a position header or an individual lot. +/// A row in the portfolio view -- position header, lot detail, or special sections. const PortfolioRow = struct { kind: Kind, symbol: []const u8, @@ -65,7 +65,7 @@ const PortfolioRow = struct { /// Number of lots for this symbol (set on position rows) lot_count: usize = 0, - const Kind = enum { position, lot, watchlist }; + const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total }; }; /// Styled line for rendering @@ -123,8 +123,11 @@ const App = struct { // Portfolio navigation cursor: usize = 0, // selected row in portfolio view expanded: [64]bool = [_]bool{false} ** 64, // which positions are expanded + cash_expanded: bool = false, // whether cash section is expanded to show per-account portfolio_rows: std.ArrayList(PortfolioRow) = .empty, 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 // Options navigation (inline expand/collapse like portfolio) options_cursor: usize = 0, // selected row in flattened options view @@ -262,11 +265,14 @@ const App = struct { if (self.active_tab == .portfolio and mouse.row > 0) { const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) { - const row_idx = content_row - self.portfolio_header_lines; - if (row_idx < self.portfolio_rows.items.len) { - self.cursor = row_idx; - self.toggleExpand(); - return ctx.consumeAndRedraw(); + 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) { + const row_idx = self.portfolio_line_to_row[line_idx]; + if (row_idx < self.portfolio_rows.items.len) { + self.cursor = row_idx; + self.toggleExpand(); + return ctx.consumeAndRedraw(); + } } } } @@ -541,7 +547,11 @@ const App = struct { self.rebuildPortfolioRows(); } }, - .lot => {}, + .lot, .option_row, .cd_row, .cash_row, .section_header => {}, + .cash_total => { + self.cash_expanded = !self.cash_expanded; + self.rebuildPortfolioRows(); + }, .watchlist => { self.setActiveSymbol(row.symbol); self.active_tab = .quote; @@ -815,17 +825,22 @@ const App = struct { var prices = std.StringHashMap(f64).init(self.allocator); defer prices.deinit(); - const syms = pf.symbols(self.allocator) catch { + // Only fetch prices for stock/ETF symbols (skip options, CDs, cash) + const syms = pf.stockSymbols(self.allocator) catch { self.setStatus("Error getting symbols"); return; }; defer self.allocator.free(syms); var latest_date: ?zfin.Date = null; + var fail_count: usize = 0; for (syms) |sym| { // Try cache first; if miss, fetch (handles new securities / stale cache) const candles_slice = self.svc.getCachedCandles(sym) orelse blk: { - const result = self.svc.getCandles(sym) catch break :blk null; + const result = self.svc.getCandles(sym) catch { + fail_count += 1; + break :blk null; + }; break :blk result.data; }; if (candles_slice) |cs| { @@ -850,6 +865,27 @@ const App = struct { return; } + // Include non-stock assets in the grand total + // Cash and CDs add equally to value and cost (no gain/loss), + // options add at cost basis (no live pricing). + // This keeps unrealized_pnl correct (only stocks contribute market gains) + // but dilutes the return% against the full portfolio cost base. + const cash_total = pf.totalCash(); + const cd_total = pf.totalCdFaceValue(); + const opt_total = pf.totalOptionCost(); + const non_stock = cash_total + cd_total + opt_total; + summary.total_value += non_stock; + summary.total_cost += non_stock; + if (summary.total_cost > 0) { + summary.unrealized_return = summary.unrealized_pnl / summary.total_cost; + } + // Reweight allocations against grand total + if (summary.total_value > 0) { + for (summary.allocations) |*a| { + a.weight = a.market_value / summary.total_value; + } + } + self.portfolio_summary = summary; self.rebuildPortfolioRows(); @@ -857,7 +893,14 @@ const App = struct { self.setActiveSymbol(summary.allocations[0].symbol); } - self.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help"); + // Show warning if any securities failed to load + if (fail_count > 0) { + var warn_buf: [128]u8 = undefined; + const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed"; + self.setStatus(warn_msg); + } else { + self.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help"); + } } fn rebuildPortfolioRows(self: *App) void { @@ -869,7 +912,7 @@ const App = struct { var lcount: usize = 0; if (self.portfolio) |pf| { for (pf.lots) |lot| { - if (std.mem.eql(u8, lot.symbol, a.symbol)) lcount += 1; + if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) lcount += 1; } } @@ -887,7 +930,7 @@ const App = struct { var matching: std.ArrayList(zfin.Lot) = .empty; defer matching.deinit(self.allocator); for (pf.lots) |lot| { - if (std.mem.eql(u8, lot.symbol, a.symbol)) { + if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) { matching.append(self.allocator, lot) catch continue; } } @@ -924,6 +967,80 @@ const App = struct { }) catch continue; } } + + // Options section + if (self.portfolio) |pf| { + if (pf.hasType(.option)) { + self.portfolio_rows.append(self.allocator, .{ + .kind = .section_header, + .symbol = "Options", + }) catch {}; + for (pf.lots) |lot| { + if (lot.lot_type == .option) { + self.portfolio_rows.append(self.allocator, .{ + .kind = .option_row, + .symbol = lot.symbol, + .lot = lot, + }) catch continue; + } + } + } + + // CDs section (sorted by maturity date, earliest first) + if (pf.hasType(.cd)) { + self.portfolio_rows.append(self.allocator, .{ + .kind = .section_header, + .symbol = "Certificates of Deposit", + }) catch {}; + var cd_lots: std.ArrayList(zfin.Lot) = .empty; + defer cd_lots.deinit(self.allocator); + for (pf.lots) |lot| { + if (lot.lot_type == .cd) { + cd_lots.append(self.allocator, lot) catch continue; + } + } + std.mem.sort(zfin.Lot, cd_lots.items, {}, struct { + fn f(ctx: void, a: zfin.Lot, b: zfin.Lot) bool { + _ = ctx; + 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); + return ad < bd; + } + }.f); + for (cd_lots.items) |lot| { + self.portfolio_rows.append(self.allocator, .{ + .kind = .cd_row, + .symbol = lot.symbol, + .lot = lot, + }) catch continue; + } + } + + // Cash section (single total row, expandable to show per-account) + if (pf.hasType(.cash)) { + self.portfolio_rows.append(self.allocator, .{ + .kind = .section_header, + .symbol = "Cash", + }) catch {}; + // Total cash row + self.portfolio_rows.append(self.allocator, .{ + .kind = .cash_total, + .symbol = "CASH", + }) catch {}; + // Per-account cash rows (expanded when cash_total is toggled) + if (self.cash_expanded) { + for (pf.lots) |lot| { + if (lot.lot_type == .cash) { + self.portfolio_rows.append(self.allocator, .{ + .kind = .cash_row, + .symbol = lot.account orelse "Unknown", + .lot = lot, + }) catch continue; + } + } + } + } + } } fn loadPerfData(self: *App) void { @@ -1349,9 +1466,11 @@ const App = struct { // Track header line count for mouse click mapping (after all header lines) self.portfolio_header_lines = lines.items.len; + self.portfolio_line_count = 0; // Data rows for (self.portfolio_rows.items, 0..) |row, ri| { + const lines_before = lines.items.len; const is_cursor = ri == self.cursor; const is_active_sym = std.mem.eql(u8, row.symbol, self.symbol); switch (row.kind) { @@ -1381,21 +1500,48 @@ const App = struct { // Date + ST/LT: show for single-lot, blank for multi-lot var pos_date_buf: [10]u8 = undefined; - const date_col: []const u8 = if (!is_multi) blk: { + var date_col: []const u8 = ""; + var acct_col: []const u8 = ""; + if (!is_multi) { if (self.portfolio) |pf| { for (pf.lots) |lot| { - if (std.mem.eql(u8, lot.symbol, a.symbol)) { + if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) { const ds = lot.open_date.format(&pos_date_buf); const indicator = fmt.capitalGainsIndicator(lot.open_date); - break :blk std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds; + date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds; + acct_col = lot.account orelse ""; + break; } } } - break :blk ""; - } else ""; + } else { + // Multi-lot: show account if all lots share the same one + if (self.portfolio) |pf| { + var common_acct: ?[]const u8 = null; + var mixed = false; + for (pf.lots) |lot| { + if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) { + if (common_acct) |ca| { + const la = lot.account orelse ""; + if (!std.mem.eql(u8, ca, la)) { + mixed = true; + break; + } + } else { + common_acct = lot.account orelse ""; + } + } + } + if (!mixed) { + acct_col = common_acct orelse ""; + } else { + acct_col = "Multiple"; + } + } + } - 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}", .{ - arrow, star, a.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, + 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}", .{ + arrow, star, a.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, acct_col, }); // base: neutral text for main cols, green/red only for gain/loss col @@ -1472,7 +1618,106 @@ const App = struct { const row_style = if (is_cursor) th.selectStyle() else th.contentStyle(); try lines.append(arena, .{ .text = text, .style = row_style }); }, + .section_header => { + // Blank line before section header + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + const hdr_text = try std.fmt.allocPrint(arena, " {s}", .{row.symbol}); + const hdr_style = if (is_cursor) th.selectStyle() else th.headerStyle(); + 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", "Total Cost", "Account", + }); + 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", + }); + try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() }); + } + }, + .option_row => { + if (row.lot) |lot| { + // Options: symbol (description), qty (contracts), cost/contract, cost basis, account + const qty = lot.shares; // negative = short + const cost_per = lot.open_price; // per-contract cost + const total_cost = @abs(qty) * cost_per; + var cost_buf3: [24]u8 = undefined; + var total_buf: [24]u8 = undefined; + const acct_col2: []const u8 = lot.account orelse ""; + const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{ + lot.symbol, + qty, + fmt.fmtMoney2(&cost_buf3, cost_per), + fmt.fmtMoney(&total_buf, total_cost), + acct_col2, + }); + const row_style2 = if (is_cursor) th.selectStyle() else th.contentStyle(); + try lines.append(arena, .{ .text = text, .style = row_style2 }); + } + }, + .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.fmtMoney(&face_buf, lot.shares), + rate_str, + mat_str, + note_display, + acct_col3, + }); + const row_style3 = if (is_cursor) th.selectStyle() else th.contentStyle(); + try lines.append(arena, .{ .text = text, .style = row_style3 }); + } + }, + .cash_total => { + if (self.portfolio) |pf| { + const total_cash = pf.totalCash(); + var cash_buf: [24]u8 = undefined; + const arrow3: []const u8 = if (self.cash_expanded) "v " else "> "; + const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{ + arrow3, + fmt.fmtMoney(&cash_buf, total_cash), + }); + const row_style4 = if (is_cursor) th.selectStyle() else th.contentStyle(); + try lines.append(arena, .{ .text = text, .style = row_style4 }); + } + }, + .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), + }); + const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle(); + try lines.append(arena, .{ .text = text, .style = row_style5 }); + } + }, } + // Map all styled lines produced by this row back to the row index + const lines_after = lines.items.len; + for (lines_before..lines_after) |li| { + const map_idx = li - self.portfolio_header_lines; + if (map_idx < self.portfolio_line_to_row.len) { + self.portfolio_line_to_row[map_idx] = ri; + } + } + self.portfolio_line_count = lines_after - self.portfolio_header_lines; } // Render