diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 7e5111c..0a98e24 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -487,25 +487,7 @@ pub fn compareSchwabSummary( if (portfolio_acct) |pa| { pf_cash = portfolio.cashForAccount(pa); - - const acct_positions = portfolio.positionsForAccount(allocator, pa) catch &.{}; - defer allocator.free(acct_positions); - for (acct_positions) |pos| { - const price = prices.get(pos.symbol) orelse pos.avg_cost; - pf_total += pos.shares * price * pos.price_ratio; - } - - // Add cash, CDs, options for this account - for (portfolio.lots) |lot| { - const lot_acct = lot.account orelse continue; - if (!std.mem.eql(u8, lot_acct, pa)) continue; - switch (lot.security_type) { - .cash => pf_total += lot.shares, - .cd => pf_total += lot.shares, - .option => pf_total += @abs(lot.shares) * lot.open_price * lot.multiplier, - else => {}, - } - } + pf_total = portfolio.totalForAccount(allocator, pa, prices); } const cash_delta = if (sa.cash) |sc| sc - pf_cash else null; @@ -791,6 +773,7 @@ pub fn compareAccounts( const acct_positions = portfolio.positionsForAccount(allocator, portfolio_acct_name.?) catch &.{}; defer allocator.free(acct_positions); + var found_stock = false; for (acct_positions) |pos| { if (!std.mem.eql(u8, pos.symbol, bp.symbol) and !std.mem.eql(u8, pos.lot_symbol, bp.symbol)) @@ -801,6 +784,30 @@ pub fn compareAccounts( pf_value = pos.shares * price * pos.price_ratio; try matched_symbols.put(pos.symbol, {}); try matched_symbols.put(pos.lot_symbol, {}); + found_stock = true; + } + + if (!found_stock) { + for (portfolio.lots) |lot| { + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, portfolio_acct_name.?)) continue; + if (!lot.isOpen()) continue; + if (!std.mem.eql(u8, lot.symbol, bp.symbol)) continue; + switch (lot.security_type) { + .cd => { + pf_shares += lot.shares; + pf_value += lot.shares; + pf_price = 1.0; + }, + .option => { + pf_shares += lot.shares; + pf_value += @abs(lot.shares) * lot.open_price * lot.multiplier; + pf_price = lot.open_price * lot.multiplier; + }, + else => {}, + } + } + if (pf_shares != 0) try matched_symbols.put(bp.symbol, {}); } } @@ -864,6 +871,63 @@ pub fn compareAccounts( .only_in_portfolio = true, }); } + + // Portfolio-only CDs and options + for (portfolio.lots) |lot| { + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, pa)) continue; + if (!lot.isOpen()) continue; + if (lot.security_type != .cd and lot.security_type != .option) continue; + if (matched_symbols.contains(lot.symbol)) continue; + + try matched_symbols.put(lot.symbol, {}); + + var pf_shares: f64 = 0; + var pf_value: f64 = 0; + var pf_price: ?f64 = null; + var is_cd = false; + + // Aggregate all lots with same symbol in this account + for (portfolio.lots) |lot2| { + const la2 = lot2.account orelse continue; + if (!std.mem.eql(u8, la2, pa)) continue; + if (!lot2.isOpen()) continue; + if (!std.mem.eql(u8, lot2.symbol, lot.symbol)) continue; + switch (lot2.security_type) { + .cd => { + pf_shares += lot2.shares; + pf_value += lot2.shares; + pf_price = 1.0; + is_cd = true; + }, + .option => { + pf_shares += lot2.shares; + pf_value += @abs(lot2.shares) * lot2.open_price * lot2.multiplier; + pf_price = lot2.open_price * lot2.multiplier; + }, + else => {}, + } + } + + if (pf_value != 0 or pf_shares != 0) { + portfolio_total += pf_value; + has_discrepancies = true; + try comparisons.append(allocator, .{ + .symbol = lot.symbol, + .portfolio_shares = pf_shares, + .brokerage_shares = null, + .portfolio_price = pf_price, + .brokerage_price = null, + .portfolio_value = pf_value, + .brokerage_value = null, + .shares_delta = null, + .value_delta = null, + .is_cash = is_cd, + .only_in_brokerage = false, + .only_in_portfolio = true, + }); + } + } } try results.append(allocator, .{ diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index be54e3c..47298d8 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -93,7 +93,12 @@ pub const Lot = struct { } pub fn isOpen(self: Lot) bool { - return self.close_date == null; + if (self.close_date != null) return false; + if (self.maturity_date) |mat| { + const today = Date.fromEpoch(std.time.timestamp()); + if (!today.lessThan(mat)) return false; + } + return true; } pub fn costBasis(self: Lot) f64 { @@ -378,6 +383,41 @@ pub const Portfolio = struct { return total; } + /// Total value of non-stock holdings (cash, CDs, options) for a single account. + /// Only includes open lots (respects close_date and maturity_date). + pub fn nonStockValueForAccount(self: Portfolio, account_name: []const u8) f64 { + var total: f64 = 0; + for (self.lots) |lot| { + if (!lot.isOpen()) continue; + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, account_name)) continue; + switch (lot.security_type) { + .cash => total += lot.shares, + .cd => total += lot.shares, + .option => total += @abs(lot.shares) * lot.open_price * lot.multiplier, + else => {}, + } + } + return total; + } + + /// Total value of an account: stocks (priced from the given map, falling back to avg_cost) + /// plus cash, CDs, and options. Only includes open lots. + pub fn totalForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8, prices: std.StringHashMap(f64)) f64 { + var total: f64 = 0; + + const acct_positions = self.positionsForAccount(allocator, account_name) catch return self.nonStockValueForAccount(account_name); + defer allocator.free(acct_positions); + + for (acct_positions) |pos| { + const price = prices.get(pos.symbol) orelse pos.avg_cost; + total += pos.shares * price * pos.price_ratio; + } + + total += self.nonStockValueForAccount(account_name); + return total; + } + /// Total cost basis of all open stock lots. pub fn totalCostBasis(self: Portfolio) f64 { var total: f64 = 0; diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 1b20393..ba20193 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -719,7 +719,7 @@ const FilteredTotals = struct { /// Compute total value and cost across all asset types for the active account filter. /// Returns {0, 0} if no filter is active. fn computeFilteredTotals(app: *const App) FilteredTotals { - if (app.account_filter == null) return .{ .value = 0, .cost = 0 }; + const af = app.account_filter orelse return .{ .value = 0, .cost = 0 }; var value: f64 = 0; var cost: f64 = 0; if (app.portfolio_summary) |s| { @@ -732,25 +732,9 @@ fn computeFilteredTotals(app: *const App) FilteredTotals { } } if (app.portfolio) |pf| { - for (pf.lots) |lot| { - if (!matchesAccountFilter(app, lot.account)) continue; - switch (lot.security_type) { - .cash => { - value += lot.shares; - cost += lot.shares; - }, - .cd => { - value += lot.shares; - cost += lot.shares; - }, - .option => { - const opt_cost = @abs(lot.shares) * lot.open_price; - value += opt_cost; - cost += opt_cost; - }, - else => {}, - } - } + const ns = pf.nonStockValueForAccount(af); + value += ns; + cost += ns; } return .{ .value = value, .cost = cost }; }