diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 9be1849..0101ff2 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -38,6 +38,8 @@ pub const TaxType = enum { pub const AccountTaxEntry = struct { account: []const u8, tax_type: TaxType, + institution: ?[]const u8 = null, + account_number: ?[]const u8 = null, }; /// Parsed account metadata. @@ -48,6 +50,8 @@ pub const AccountMap = struct { pub fn deinit(self: *AccountMap) void { for (self.entries) |e| { self.allocator.free(e.account); + if (e.institution) |s| self.allocator.free(s); + if (e.account_number) |s| self.allocator.free(s); } self.allocator.free(self.entries); } @@ -61,15 +65,42 @@ pub const AccountMap = struct { } return "Unknown"; } + + /// Find the portfolio account name for a given institution + account number. + pub fn findByInstitutionAccount(self: AccountMap, institution: []const u8, account_number: []const u8) ?[]const u8 { + for (self.entries) |e| { + if (e.institution) |inst| { + if (e.account_number) |num| { + if (std.mem.eql(u8, inst, institution) and std.mem.eql(u8, num, account_number)) + return e.account; + } + } + } + return null; + } + + /// Return all entries matching a given institution. + pub fn entriesForInstitution(self: AccountMap, institution: []const u8) []const AccountTaxEntry { + var count: usize = 0; + for (self.entries) |e| { + if (e.institution) |inst| { + if (std.mem.eql(u8, inst, institution)) count += 1; + } + } + if (count == 0) return &.{}; + return self.entries; + } }; /// Parse an accounts.srf file into an AccountMap. -/// Each record has: account::,tax_type:: +/// Each record has: account::,tax_type::[,institution::][,account_number::] pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap { var entries = std.ArrayList(AccountTaxEntry).empty; errdefer { for (entries.items) |e| { allocator.free(e.account); + if (e.institution) |s| allocator.free(s); + if (e.account_number) |s| allocator.free(s); } entries.deinit(allocator); } @@ -83,6 +114,8 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun try entries.append(allocator, .{ .account = try allocator.dupe(u8, entry.account), .tax_type = entry.tax_type, + .institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null, + .account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null, }); } diff --git a/src/commands/audit.zig b/src/commands/audit.zig new file mode 100644 index 0000000..eb47419 --- /dev/null +++ b/src/commands/audit.zig @@ -0,0 +1,743 @@ +const std = @import("std"); +const zfin = @import("../root.zig"); +const cli = @import("common.zig"); +const fmt = cli.fmt; +const analysis = @import("../analytics/analysis.zig"); + +// ── Brokerage position (normalized from any source) ───────── + +/// A single position row from a brokerage export, normalized to a common format. +pub const BrokeragePosition = struct { + account_number: []const u8, + account_name: []const u8, + symbol: []const u8, + description: []const u8, + quantity: ?f64, + current_value: ?f64, + cost_basis: ?f64, + is_cash: bool, +}; + +/// Parsed brokerage data grouped by account. +pub const BrokerageAccount = struct { + account_number: []const u8, + account_name: []const u8, + portfolio_account: ?[]const u8, + positions: []const BrokeragePosition, + total_value: f64, + cash_value: f64, +}; + +// ── Fidelity CSV parser ───────────────────────────────────── +// +// Limitations of this CSV parser: +// +// 1. NOT a general-purpose CSV parser. It handles the specific format +// exported by Fidelity's "Download Positions" feature. +// +// 2. Does NOT handle quoted fields containing commas. Fidelity's export +// does not quote fields with commas in practice (description fields +// use spaces, not commas), but a truly compliant RFC 4180 parser would +// need to handle "field,with,commas" as a single value. +// +// 3. Does NOT handle escaped quotes ("" inside quoted fields). +// +// 4. Does NOT handle multi-line values (newlines inside quoted fields). +// +// 5. Assumes UTF-8 with optional BOM (which Fidelity includes). +// +// 6. Stops parsing at the first blank line, which separates position data +// from the Fidelity legal disclaimer footer. +// +// 7. Hardcodes the expected column layout. If Fidelity changes the CSV +// format (adds/removes/reorders columns), this parser will break. +// The header row is validated to catch this. +// +// 8. Dollar values like "$1,234.56" and "+$1,234.56" are stripped of +// $, commas, and leading +/- signs. Negative values wrapped in +// parentheses are NOT handled (Fidelity uses -$X.XX format). +// +// 9. Money market rows (symbol ending in **) are treated as cash. +// +// For a production-grade CSV parser, consider a library that handles +// RFC 4180 fully (quoted fields, escaping, multi-line values). + +const fidelity_expected_columns = 16; + +/// Column indices in the Fidelity CSV export. +/// Based on: Account Number, Account Name, Symbol, Description, Quantity, +/// Last Price, Last Price Change, Current Value, Today's Gain/Loss Dollar, +/// Today's Gain/Loss Percent, Total Gain/Loss Dollar, Total Gain/Loss Percent, +/// Percent Of Account, Cost Basis Total, Average Cost Basis, Type +const FidelityCol = struct { + const account_number = 0; + const account_name = 1; + const symbol = 2; + const description = 3; + const quantity = 4; + const current_value = 7; + const cost_basis_total = 13; + const type_col = 15; +}; + +/// Parse a Fidelity CSV positions export into BrokeragePosition slices. +/// All string fields in the returned positions are slices into `data`, +/// so the caller must keep `data` alive for as long as the positions are used. +/// Only the returned slice itself is heap-allocated (caller must free it). +pub fn parseFidelityCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition { + var positions = std.ArrayList(BrokeragePosition).empty; + errdefer positions.deinit(allocator); + + // Skip UTF-8 BOM if present + var content = data; + if (content.len >= 3 and content[0] == 0xEF and content[1] == 0xBB and content[2] == 0xBF) { + content = content[3..]; + } + + var lines = std.mem.splitScalar(u8, content, '\n'); + + // Validate header row + const header_line = lines.next() orelse return error.EmptyFile; + const header_trimmed = std.mem.trimRight(u8, header_line, &.{ '\r', ' ' }); + if (header_trimmed.len == 0) return error.EmptyFile; + if (!std.mem.startsWith(u8, header_trimmed, "Account Number")) { + return error.UnexpectedHeader; + } + + // Parse data rows + while (lines.next()) |line| { + const trimmed = std.mem.trimRight(u8, line, &.{ '\r', ' ' }); + if (trimmed.len == 0) break; + + // Skip lines starting with " (disclaimer text) + if (trimmed[0] == '"') break; + + var col_iter = std.mem.splitScalar(u8, trimmed, ','); + var cols: [fidelity_expected_columns][]const u8 = undefined; + var col_count: usize = 0; + while (col_iter.next()) |col| { + if (col_count < fidelity_expected_columns) { + cols[col_count] = col; + col_count += 1; + } + } + if (col_count < fidelity_expected_columns) continue; + + const symbol_raw = std.mem.trim(u8, cols[FidelityCol.symbol], &.{ ' ', '"' }); + if (symbol_raw.len == 0) continue; + + // Money market funds (symbol ending in **) are cash positions. + // Note: Fidelity's Type column says "Cash" vs "Margin" to indicate + // the account settlement type, NOT that the security is cash. + // Only the ** suffix reliably identifies money market / cash holdings. + const is_cash = std.mem.endsWith(u8, symbol_raw, "**"); + + // Strip ** suffix from money market symbols for display + const symbol_clean = if (std.mem.endsWith(u8, symbol_raw, "**")) + symbol_raw[0 .. symbol_raw.len - 2] + else + symbol_raw; + + try positions.append(allocator, .{ + .account_number = std.mem.trim(u8, cols[FidelityCol.account_number], &.{ ' ', '"' }), + .account_name = std.mem.trim(u8, cols[FidelityCol.account_name], &.{ ' ', '"' }), + .symbol = symbol_clean, + .description = std.mem.trim(u8, cols[FidelityCol.description], &.{ ' ', '"' }), + .quantity = if (is_cash) null else parseDollarAmount(cols[FidelityCol.quantity]), + .current_value = parseDollarAmount(cols[FidelityCol.current_value]), + .cost_basis = if (is_cash) null else parseDollarAmount(cols[FidelityCol.cost_basis_total]), + .is_cash = is_cash, + }); + } + + return positions.toOwnedSlice(allocator); +} + +/// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00". +/// Strips $, commas, and +/- prefix. Returns null for empty or unparseable values. +fn parseDollarAmount(raw: []const u8) ?f64 { + const trimmed = std.mem.trim(u8, raw, &.{ ' ', '"' }); + if (trimmed.len == 0) return null; + + // Strip leading +/- and $, remove commas + var buf: [64]u8 = undefined; + var pos: usize = 0; + var negative = false; + for (trimmed) |c| { + if (c == '-') { + negative = true; + } else if (c == '$' or c == '+' or c == ',') { + continue; + } else { + if (pos >= buf.len) return null; + buf[pos] = c; + pos += 1; + } + } + if (pos == 0) return null; + + const val = std.fmt.parseFloat(f64, buf[0..pos]) catch return null; + return if (negative) -val else val; +} + +// ── Audit logic ───────────────────────────────────────────── + +/// Comparison result for a single symbol within an account. +pub const SymbolComparison = struct { + symbol: []const u8, + portfolio_shares: f64, + brokerage_shares: ?f64, + portfolio_value: f64, + brokerage_value: ?f64, + shares_delta: ?f64, + value_delta: ?f64, + is_cash: bool, + only_in_brokerage: bool, + only_in_portfolio: bool, +}; + +/// Comparison result for a single account. +pub const AccountComparison = struct { + account_name: []const u8, + brokerage_name: []const u8, + account_number: []const u8, + comparisons: []const SymbolComparison, + portfolio_total: f64, + brokerage_total: f64, + total_delta: f64, + has_discrepancies: bool, +}; + +/// Build per-account comparisons between portfolio.srf and brokerage data. +pub fn compareAccounts( + allocator: std.mem.Allocator, + portfolio: zfin.Portfolio, + brokerage_positions: []const BrokeragePosition, + account_map: analysis.AccountMap, + institution: []const u8, + prices: std.StringHashMap(f64), +) ![]AccountComparison { + var results = std.ArrayList(AccountComparison).empty; + errdefer results.deinit(allocator); + + // Group brokerage positions by account number + var brokerage_accounts = std.StringHashMap(std.ArrayList(BrokeragePosition)).init(allocator); + defer { + var it = brokerage_accounts.valueIterator(); + while (it.next()) |v| v.deinit(allocator); + brokerage_accounts.deinit(); + } + + for (brokerage_positions) |bp| { + const entry = try brokerage_accounts.getOrPut(bp.account_number); + if (!entry.found_existing) { + entry.value_ptr.* = .empty; + } + try entry.value_ptr.append(allocator, bp); + } + + // For each brokerage account, find the matching portfolio account and compare + var acct_iter = brokerage_accounts.iterator(); + while (acct_iter.next()) |kv| { + const acct_num = kv.key_ptr.*; + const broker_positions = kv.value_ptr.items; + if (broker_positions.len == 0) continue; + + const broker_name = broker_positions[0].account_name; + const portfolio_acct_name = account_map.findByInstitutionAccount(institution, acct_num); + + var comparisons = std.ArrayList(SymbolComparison).empty; + errdefer comparisons.deinit(allocator); + + var portfolio_total: f64 = 0; + var brokerage_total: f64 = 0; + var has_discrepancies = false; + + // Track which portfolio symbols we've matched + var matched_symbols = std.StringHashMap(void).init(allocator); + defer matched_symbols.deinit(); + + // Compare each brokerage position against portfolio + for (broker_positions) |bp| { + const bp_value = bp.current_value orelse 0; + brokerage_total += bp_value; + + if (portfolio_acct_name == null) { + try comparisons.append(allocator, .{ + .symbol = bp.symbol, + .portfolio_shares = 0, + .brokerage_shares = bp.quantity, + .portfolio_value = 0, + .brokerage_value = bp.current_value, + .shares_delta = if (bp.quantity) |q| q else null, + .value_delta = bp.current_value, + .is_cash = bp.is_cash, + .only_in_brokerage = true, + .only_in_portfolio = false, + }); + has_discrepancies = true; + continue; + } + + // Sum portfolio lots for this symbol+account + var pf_shares: f64 = 0; + var pf_value: f64 = 0; + + if (bp.is_cash) { + pf_shares = portfolio.cashForAccount(portfolio_acct_name.?); + pf_value = pf_shares; + } else { + const acct_positions = portfolio.positionsForAccount(allocator, portfolio_acct_name.?) catch &.{}; + defer allocator.free(acct_positions); + + for (acct_positions) |pos| { + if (!std.mem.eql(u8, pos.symbol, bp.symbol) and + !std.mem.eql(u8, pos.lot_symbol, bp.symbol)) + continue; + pf_shares = pos.shares; + const price = prices.get(pos.symbol) orelse pos.avg_cost; + pf_value = pos.shares * price * pos.price_ratio; + try matched_symbols.put(pos.symbol, {}); + try matched_symbols.put(pos.lot_symbol, {}); + } + } + + try matched_symbols.put(bp.symbol, {}); + portfolio_total += pf_value; + + const shares_delta = if (bp.quantity) |bq| bq - pf_shares else null; + const value_delta = if (bp.current_value) |bv| bv - pf_value else null; + + const shares_match = if (shares_delta) |d| @abs(d) < 0.01 else true; + const value_match = if (value_delta) |d| @abs(d) < 1.0 else true; + + if (!shares_match or !value_match) has_discrepancies = true; + + try comparisons.append(allocator, .{ + .symbol = bp.symbol, + .portfolio_shares = pf_shares, + .brokerage_shares = bp.quantity, + .portfolio_value = pf_value, + .brokerage_value = bp.current_value, + .shares_delta = shares_delta, + .value_delta = value_delta, + .is_cash = bp.is_cash, + .only_in_brokerage = pf_shares == 0 and pf_value == 0, + .only_in_portfolio = false, + }); + } + + // Find portfolio-only positions (in portfolio but not in brokerage) + if (portfolio_acct_name) |pa| { + const acct_positions = portfolio.positionsForAccount(allocator, pa) catch &.{}; + defer allocator.free(acct_positions); + + for (acct_positions) |pos| { + if (matched_symbols.contains(pos.symbol)) continue; + if (matched_symbols.contains(pos.lot_symbol)) continue; + + try matched_symbols.put(pos.symbol, {}); + + const price = prices.get(pos.symbol) orelse pos.avg_cost; + const mv = pos.shares * price * pos.price_ratio; + portfolio_total += mv; + + has_discrepancies = true; + try comparisons.append(allocator, .{ + .symbol = pos.symbol, + .portfolio_shares = pos.shares, + .brokerage_shares = null, + .portfolio_value = mv, + .brokerage_value = null, + .shares_delta = null, + .value_delta = null, + .is_cash = false, + .only_in_brokerage = false, + .only_in_portfolio = true, + }); + } + } + + try results.append(allocator, .{ + .account_name = portfolio_acct_name orelse "", + .brokerage_name = broker_name, + .account_number = acct_num, + .comparisons = try comparisons.toOwnedSlice(allocator), + .portfolio_total = portfolio_total, + .brokerage_total = brokerage_total, + .total_delta = brokerage_total - portfolio_total, + .has_discrepancies = has_discrepancies, + }); + } + + return results.toOwnedSlice(allocator); +} + +// ── Display ───────────────────────────────────────────────── + +fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void { + try cli.setBold(out, color); + try out.print("\nPortfolio Audit\n", .{}); + try cli.reset(out, color); + try out.print("========================================\n\n", .{}); + + var total_portfolio: f64 = 0; + var total_brokerage: f64 = 0; + var discrepancy_count: usize = 0; + + for (results) |acct| { + // Account header + try cli.setBold(out, color); + if (acct.account_name.len > 0) { + try out.print(" {s}", .{acct.account_name}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number }); + } else { + try out.print(" {s} #{s}", .{ acct.brokerage_name, acct.account_number }); + try cli.reset(out, color); + try cli.setFg(out, color, cli.CLR_WARNING); + try out.print(" (unmapped — add account_number to accounts.srf)\n", .{}); + } + try cli.reset(out, color); + + // Column headers + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:<24} {s:>12} {s:>12} {s:>14} {s:>14} {s}\n", .{ + "Symbol", "PF Shares", "BR Shares", "PF Value", "BR Value", "Status", + }); + try cli.reset(out, color); + + for (acct.comparisons) |cmp| { + var pf_shares_buf: [16]u8 = undefined; + var br_shares_buf: [16]u8 = undefined; + var pf_val_buf: [24]u8 = undefined; + var br_val_buf: [24]u8 = undefined; + + const pf_shares_str = if (cmp.is_cash) + "--" + else + std.fmt.bufPrint(&pf_shares_buf, "{d:.3}", .{cmp.portfolio_shares}) catch "?"; + + const br_shares_str = if (cmp.is_cash) + "--" + else if (cmp.brokerage_shares) |s| + (std.fmt.bufPrint(&br_shares_buf, "{d:.3}", .{s}) catch "?") + else + "--"; + + const pf_val_str = fmt.fmtMoneyAbs(&pf_val_buf, cmp.portfolio_value); + + const br_val_str = if (cmp.brokerage_value) |v| + fmt.fmtMoneyAbs(&br_val_buf, @abs(v)) + else + "--"; + + // Determine status + var status_buf: [64]u8 = undefined; + const status: []const u8 = blk: { + if (cmp.only_in_brokerage) break :blk "Brokerage only"; + if (cmp.only_in_portfolio) break :blk "Portfolio only"; + + const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else true; + const value_ok = if (cmp.value_delta) |d| @abs(d) < 1.0 else true; + + if (shares_ok and value_ok) break :blk "ok"; + + if (!shares_ok) { + if (cmp.shares_delta) |d| { + const sign: []const u8 = if (d > 0) "+" else ""; + break :blk std.fmt.bufPrint(&status_buf, "Shares {s}{d:.3}", .{ sign, d }) catch "Shares mismatch"; + } + } + if (!value_ok) { + if (cmp.value_delta) |d| { + var delta_buf: [24]u8 = undefined; + const delta_str = fmt.fmtMoneyAbs(&delta_buf, @abs(d)); + const sign: []const u8 = if (d >= 0) "+" else "-"; + break :blk std.fmt.bufPrint(&status_buf, "Value {s}{s}", .{ sign, delta_str }) catch "Value mismatch"; + } + } + break :blk "Mismatch"; + }; + + // Color the status + const is_ok = std.mem.eql(u8, status, "ok"); + if (!is_ok) discrepancy_count += 1; + + try out.print(" {s:<24} {s:>12} {s:>12} {s:>14} {s:>14} ", .{ + cmp.symbol, pf_shares_str, br_shares_str, pf_val_str, br_val_str, + }); + if (is_ok) { + try cli.setFg(out, color, cli.CLR_POSITIVE); + } else { + try cli.setFg(out, color, cli.CLR_WARNING); + } + try out.print("{s}\n", .{status}); + try cli.reset(out, color); + } + + // Account totals + var pf_total_buf: [24]u8 = undefined; + var br_total_buf: [24]u8 = undefined; + var delta_buf: [24]u8 = undefined; + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:<24} {s:>12} {s:>12} {s:>14} {s:>14} ", .{ + "", "", "", fmt.fmtMoneyAbs(&pf_total_buf, acct.portfolio_total), fmt.fmtMoneyAbs(&br_total_buf, acct.brokerage_total), + }); + try cli.reset(out, color); + + if (@abs(acct.total_delta) < 1.0) { + try cli.setFg(out, color, cli.CLR_POSITIVE); + try out.print("ok\n", .{}); + } else { + try cli.setFg(out, color, cli.CLR_WARNING); + const sign: []const u8 = if (acct.total_delta >= 0) "+" else "-"; + try out.print("Delta {s}{s}\n", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(acct.total_delta)) }); + } + try cli.reset(out, color); + try out.print("\n", .{}); + + total_portfolio += acct.portfolio_total; + total_brokerage += acct.brokerage_total; + } + + // Grand totals + var pf_grand_buf: [24]u8 = undefined; + var br_grand_buf: [24]u8 = undefined; + var grand_delta_buf: [24]u8 = undefined; + const grand_delta = total_brokerage - total_portfolio; + + try cli.setBold(out, color); + try out.print(" Total: portfolio {s} brokerage {s}", .{ + fmt.fmtMoneyAbs(&pf_grand_buf, total_portfolio), + fmt.fmtMoneyAbs(&br_grand_buf, total_brokerage), + }); + try cli.reset(out, color); + + if (@abs(grand_delta) < 1.0) { + try cli.setFg(out, color, cli.CLR_POSITIVE); + try out.print(" ok", .{}); + } else { + try cli.setFg(out, color, cli.CLR_WARNING); + const sign: []const u8 = if (grand_delta >= 0) "+" else "-"; + try out.print(" delta {s}{s}", .{ sign, fmt.fmtMoneyAbs(&grand_delta_buf, @abs(grand_delta)) }); + } + try cli.reset(out, color); + try out.print("\n", .{}); + + if (discrepancy_count > 0) { + try cli.setFg(out, color, cli.CLR_WARNING); + try out.print(" {d} discrepancies found\n", .{discrepancy_count}); + try cli.reset(out, color); + } else { + try cli.setFg(out, color, cli.CLR_POSITIVE); + try out.print(" All positions match\n", .{}); + try cli.reset(out, color); + } + try out.print("\n", .{}); +} + +// ── CLI entry point ───────────────────────────────────────── + +pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const []const u8, color: bool, out: *std.Io.Writer) !void { + // Parse flags: --fidelity [--portfolio ] + var fidelity_csv: ?[]const u8 = null; + var portfolio_path: []const u8 = "portfolio.srf"; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--fidelity") and i + 1 < args.len) { + i += 1; + fidelity_csv = args[i]; + } else if ((std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) and i + 1 < args.len) { + i += 1; + portfolio_path = args[i]; + } else if (std.mem.eql(u8, args[i], "--no-color")) { + // handled globally + } + } + + if (fidelity_csv == null) { + try cli.stderrPrint("Usage: zfin audit --fidelity [-p ]\n"); + return; + } + + // Load portfolio + const pf_data = std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch { + try cli.stderrPrint("Error: Cannot read portfolio file\n"); + return; + }; + defer allocator.free(pf_data); + + var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch { + try cli.stderrPrint("Error: Cannot parse portfolio file\n"); + return; + }; + defer portfolio.deinit(); + + // Load accounts.srf + const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0; + const acct_path = std.fmt.allocPrint(allocator, "{s}accounts.srf", .{portfolio_path[0..dir_end]}) catch return; + defer allocator.free(acct_path); + + const acct_data = std.fs.cwd().readFileAlloc(allocator, acct_path, 1024 * 1024) catch { + try cli.stderrPrint("Error: Cannot read accounts.srf (needed for account number mapping)\n"); + return; + }; + defer allocator.free(acct_data); + + var account_map = analysis.parseAccountsFile(allocator, acct_data) catch { + try cli.stderrPrint("Error: Cannot parse accounts.srf\n"); + return; + }; + defer account_map.deinit(); + + // Load brokerage data + if (fidelity_csv) |csv_path| { + const csv_data = std.fs.cwd().readFileAlloc(allocator, csv_path, 10 * 1024 * 1024) catch { + var msg_buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n"; + try cli.stderrPrint(msg); + return; + }; + defer allocator.free(csv_data); + + const brokerage_positions = parseFidelityCsv(allocator, csv_data) catch { + try cli.stderrPrint("Error: Cannot parse Fidelity CSV (unexpected format?)\n"); + return; + }; + defer allocator.free(brokerage_positions); + + // Get cached prices (no network fetching — use whatever is cached) + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const positions = try portfolio.positions(allocator); + defer allocator.free(positions); + + for (positions) |pos| { + if (svc.getCachedLastClose(pos.symbol)) |close| { + try prices.put(pos.symbol, close); + } + } + + // Also resolve manual prices + for (portfolio.lots) |lot| { + if (lot.price) |p| { + if (!prices.contains(lot.priceSymbol())) { + try prices.put(lot.priceSymbol(), p * lot.price_ratio); + } + } + } + + const results = try compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try displayResults(results, color, out); + } +} + +// ── Tests ──────────────────────────────────────────────────── + +test "parseDollarAmount" { + try std.testing.expectApproxEqAbs(@as(f64, 1234.56), parseDollarAmount("$1,234.56").?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 7140.33), parseDollarAmount("$7140.33").?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 3732.40), parseDollarAmount("+$3,732.40").?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, -6300.00), parseDollarAmount("-$6,300.00").?, 0.01); + try std.testing.expect(parseDollarAmount("") == null); + try std.testing.expect(parseDollarAmount(" ") == null); + try std.testing.expectApproxEqAbs(@as(f64, 301), parseDollarAmount("301").?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 2387.616), parseDollarAmount("2387.616").?, 0.001); +} + +test "parseFidelityCsv basic" { + const csv = + "\xEF\xBB\xBF" ++ // BOM + "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\r\n" ++ + "Z123,Individual,FZFXX**,HELD IN MONEY MARKET,,,,$5000.00,,,,,50%,,,Cash,\r\n" ++ + "Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,50%,$10000.00,$100.00,Margin,\r\n" ++ + "\r\n" ++ + "\"Disclaimer text\"\r\n"; + + const allocator = std.testing.allocator; + const positions = try parseFidelityCsv(allocator, csv); + defer allocator.free(positions); + + try std.testing.expectEqual(@as(usize, 2), positions.len); + + // Cash position + try std.testing.expectEqualStrings("FZFXX", positions[0].symbol); + try std.testing.expect(positions[0].is_cash); + try std.testing.expectApproxEqAbs(@as(f64, 5000.00), positions[0].current_value.?, 0.01); + try std.testing.expect(positions[0].quantity == null); + + // Stock position + try std.testing.expectEqualStrings("AAPL", positions[1].symbol); + try std.testing.expect(!positions[1].is_cash); + try std.testing.expectApproxEqAbs(@as(f64, 100), positions[1].quantity.?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 15000.00), positions[1].current_value.?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[1].cost_basis.?, 0.01); +} + +test "parseFidelityCsv stops at blank line" { + const csv = + "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++ + "Z123,Individual,AAPL,APPLE INC,50,$150.00,+$2.00,$7500.00,+$100.00,+1.35%,+$2500.00,+50.00%,100%,$5000.00,$100.00,Margin,\n" ++ + "\n" ++ + "Z123,Individual,MSFT,SHOULD NOT APPEAR,10,$300.00,,,$3000.00,,,,,,,,\n"; + + const allocator = std.testing.allocator; + const positions = try parseFidelityCsv(allocator, csv); + defer allocator.free(positions); + + try std.testing.expectEqual(@as(usize, 1), positions.len); + try std.testing.expectEqualStrings("AAPL", positions[0].symbol); +} + +test "parseFidelityCsv option row" { + const csv = + "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++ + "Z123,Individual, -AMZN260515C220,AMZN MAY 15 2026 $220 CALL,-3,$21.00,+$8.60,-$6300.00,-$2580.00,-69.36%,-$3674.02,-139.92%,-8.85%,$2625.98,$8.75,Margin,\n"; + + const allocator = std.testing.allocator; + const positions = try parseFidelityCsv(allocator, csv); + defer allocator.free(positions); + + try std.testing.expectEqual(@as(usize, 1), positions.len); + try std.testing.expectEqualStrings("-AMZN260515C220", positions[0].symbol); + try std.testing.expectApproxEqAbs(@as(f64, -3), positions[0].quantity.?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, -6300.00), positions[0].current_value.?, 0.01); +} + +test "parseFidelityCsv empty file" { + const allocator = std.testing.allocator; + const result = parseFidelityCsv(allocator, ""); + try std.testing.expectError(error.EmptyFile, result); +} + +test "parseFidelityCsv wrong header" { + const allocator = std.testing.allocator; + const result = parseFidelityCsv(allocator, "Wrong,Header,Format\n"); + try std.testing.expectError(error.UnexpectedHeader, result); +} + +test "parseFidelityCsv cash account type is not cash position" { + // Fidelity's Type column says "Cash" for cash-account positions (vs "Margin"). + // This does NOT mean the security is a cash holding — only ** suffix means that. + const csv = + "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++ + "X99,HSA,QTUM,DEFIANCE QUANTUM ETF,190,$116.14,+$0.31,$22066.60,+$58.90,+0.26%,+$1185.60,+5.67%,99.64%,$20881.00,$109.90,Cash,\n"; + + const allocator = std.testing.allocator; + const positions = try parseFidelityCsv(allocator, csv); + defer allocator.free(positions); + + try std.testing.expectEqual(@as(usize, 1), positions.len); + try std.testing.expectEqualStrings("QTUM", positions[0].symbol); + try std.testing.expect(!positions[0].is_cash); + try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01); +} diff --git a/src/main.zig b/src/main.zig index c14cd8f..f9d9bb0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -20,6 +20,7 @@ const usage = \\ analysis [FILE] Show portfolio analysis (default: portfolio.srf) \\ enrich Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ lookup Look up CUSIP to ticker via OpenFIGI + \\ audit [opts] Reconcile portfolio against brokerage export \\ cache stats Show cache statistics \\ cache clear Clear all cached data \\ @@ -42,6 +43,10 @@ const usage = \\ -w, --watchlist Watchlist file \\ --refresh Force refresh (ignore cache, re-fetch all prices) \\ + \\Audit command options: + \\ --fidelity Fidelity positions CSV export (download from "All accounts" positions tab) + \\ -p, --portfolio Portfolio file (default: portfolio.srf) + \\ \\Analysis command: \\ Reads metadata.srf (classification) and accounts.srf (tax types) \\ from the same directory as the portfolio file. @@ -108,6 +113,7 @@ pub fn main() !u8 { if (args.len >= 3 and !std.mem.eql(u8, command, "cache") and !std.mem.eql(u8, command, "enrich") and + !std.mem.eql(u8, command, "audit") and !std.mem.eql(u8, command, "analysis") and !std.mem.eql(u8, command, "portfolio")) { @@ -214,6 +220,8 @@ pub fn main() !u8 { return 1; } try commands.enrich.run(allocator, &svc, args[2], out); + } else if (std.mem.eql(u8, command, "audit")) { + try commands.audit.run(allocator, &svc, args[2..], color, out); } else if (std.mem.eql(u8, command, "analysis")) { // File path is first non-flag arg (default: portfolio.srf) var analysis_file: []const u8 = "portfolio.srf"; @@ -248,6 +256,7 @@ const commands = struct { const lookup = @import("commands/lookup.zig"); const cache = @import("commands/cache.zig"); const analysis = @import("commands/analysis.zig"); + const audit = @import("commands/audit.zig"); const enrich = @import("commands/enrich.zig"); }; diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index a0b87d2..be54e3c 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -126,6 +126,9 @@ pub const Lot = struct { /// Aggregated position for a single symbol across multiple lots. pub const Position = struct { symbol: []const u8, + /// Original lot symbol before ticker aliasing (e.g. CUSIP "02315N600"). + /// Same as `symbol` when no ticker alias is set. + lot_symbol: []const u8 = "", /// Total open shares shares: f64, /// Weighted average cost basis per share (open lots only) @@ -252,6 +255,7 @@ pub const Portfolio = struct { if (!entry.found_existing) { entry.value_ptr.* = .{ .symbol = sym, + .lot_symbol = lot.symbol, .shares = 0, .avg_cost = 0, .total_cost = 0, @@ -302,6 +306,78 @@ pub const Portfolio = struct { return result.toOwnedSlice(allocator); } + /// Aggregate stock/ETF lots into positions for a single account. + /// Same logic as positions() but filtered to lots matching `account_name`. + /// Only includes positions with at least one open lot (closed-only symbols are excluded). + pub fn positionsForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8) ![]Position { + var map = std.StringHashMap(Position).init(allocator); + defer map.deinit(); + + for (self.lots) |lot| { + if (lot.security_type != .stock) continue; + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, account_name)) continue; + + const sym = lot.priceSymbol(); + const entry = try map.getOrPut(sym); + if (!entry.found_existing) { + entry.value_ptr.* = .{ + .symbol = sym, + .lot_symbol = lot.symbol, + .shares = 0, + .avg_cost = 0, + .total_cost = 0, + .open_lots = 0, + .closed_lots = 0, + .realized_gain_loss = 0, + .account = lot_acct, + .note = lot.note, + .price_ratio = lot.price_ratio, + }; + } else { + if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) { + entry.value_ptr.price_ratio = lot.price_ratio; + } + } + if (lot.isOpen()) { + entry.value_ptr.shares += lot.shares; + entry.value_ptr.total_cost += lot.costBasis(); + entry.value_ptr.open_lots += 1; + } else { + entry.value_ptr.closed_lots += 1; + entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0; + } + } + + var iter = map.valueIterator(); + while (iter.next()) |pos| { + if (pos.shares > 0) { + pos.avg_cost = pos.total_cost / pos.shares; + } + } + + var result = std.ArrayList(Position).empty; + errdefer result.deinit(allocator); + + var viter = map.valueIterator(); + while (viter.next()) |pos| { + if (pos.open_lots == 0) continue; + try result.append(allocator, pos.*); + } + return result.toOwnedSlice(allocator); + } + + /// Total cash for a single account. + pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 { + var total: f64 = 0; + for (self.lots) |lot| { + if (lot.security_type != .cash) continue; + const lot_acct = lot.account orelse continue; + if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares; + } + return total; + } + /// Total cost basis of all open stock lots. pub fn totalCostBasis(self: Portfolio) f64 { var total: f64 = 0; @@ -564,3 +640,34 @@ test "positions propagates price_ratio from lot" { } } } + +test "positionsForAccount excludes closed-only symbols" { + const allocator = std.testing.allocator; + + var lots = [_]Lot{ + // Open lot in account A + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, + // Closed lot in account A (was sold) + .{ .symbol = "XLV", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 150.0, .account = "Acct A" }, + // Open lot for same symbol in a different account + .{ .symbol = "XLV", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .account = "Acct B" }, + }; + + var portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; + + // Account A: should only see AAPL (XLV is fully closed there) + const pos_a = try portfolio.positionsForAccount(allocator, "Acct A"); + defer allocator.free(pos_a); + + try std.testing.expectEqual(@as(usize, 1), pos_a.len); + try std.testing.expectEqualStrings("AAPL", pos_a[0].symbol); + try std.testing.expectApproxEqAbs(@as(f64, 10.0), pos_a[0].shares, 0.01); + + // Account B: should see XLV with 50 shares + const pos_b = try portfolio.positionsForAccount(allocator, "Acct B"); + defer allocator.free(pos_b); + + try std.testing.expectEqual(@as(usize, 1), pos_b.len); + try std.testing.expectEqualStrings("XLV", pos_b[0].symbol); + try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01); +}