zfin/src/commands/audit.zig

1569 lines
64 KiB
Zig

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 last_price = 5;
const current_value = 7;
const cost_basis_total = 13;
const avg_cost_basis = 14;
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.
// Also treat positions as cash when both price and cost basis are $1.00
// (e.g. FDRXX "FID GOV CASH RESERVE" — no ** suffix).
const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or isUnitPriceCash(cols[FidelityCol.last_price], cols[FidelityCol.avg_cost_basis]);
// 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;
}
/// Returns true when both the last price and average cost basis parse to exactly $1.00,
/// indicating a money-market or cash-equivalent position (e.g. FDRXX).
fn isUnitPriceCash(price_raw: []const u8, cost_raw: []const u8) bool {
const price = parseDollarAmount(price_raw) orelse return false;
const cost = parseDollarAmount(cost_raw) orelse return false;
return price == 1.0 and cost == 1.0;
}
// ── Schwab CSV parser ───────────────────────────────────────
//
// Parses the per-account positions CSV exported from Schwab's website
// (Accounts → Positions → Export for a single account).
//
// Limitations:
//
// 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
// format where every field is double-quoted.
//
// 2. Handles simple quoted fields ("value") but does NOT handle escaped
// quotes ("value with ""quotes"" inside") or multi-line quoted fields.
// Schwab's export does not use these in practice.
//
// 3. The account number and name are extracted from the title line:
// "Positions for account <NAME> ...<NUM> as of ..."
//
// 4. Rows with symbol "Cash & Cash Investments" are treated as cash.
// The row with symbol "Positions Total" is skipped.
//
// 5. Hardcodes the expected column layout. If Schwab changes the CSV
// format, this parser will break. The header row is not validated
// beyond being skipped.
const schwab_expected_columns = 17;
const SchwabCol = struct {
const symbol = 0;
const price = 4;
const quantity = 5;
const market_value = 8;
const cost_basis = 9;
const asset_type = 16;
};
/// Split a Schwab CSV line on commas, stripping surrounding quotes from each field.
/// Returns the number of columns parsed. Fields are slices into the input line.
fn splitSchwabCsvLine(line: []const u8, cols: *[schwab_expected_columns][]const u8) usize {
var col_count: usize = 0;
var pos: usize = 0;
while (pos < line.len and col_count < schwab_expected_columns) {
if (line[pos] == '"') {
// Quoted field: find closing quote
const start = pos + 1;
pos = start;
while (pos < line.len and line[pos] != '"') : (pos += 1) {}
cols[col_count] = line[start..pos];
col_count += 1;
if (pos < line.len) pos += 1; // skip closing quote
if (pos < line.len and line[pos] == ',') pos += 1; // skip comma
} else if (line[pos] == ',') {
cols[col_count] = "";
col_count += 1;
pos += 1;
} else {
// Unquoted field
const start = pos;
while (pos < line.len and line[pos] != ',') : (pos += 1) {}
cols[col_count] = line[start..pos];
col_count += 1;
if (pos < line.len) pos += 1; // skip comma
}
}
return col_count;
}
/// Extract account name and number from Schwab title line.
/// Format: "Positions for account <NAME> ...<NUM> as of <TIME>, <DATE>"
/// Returns {name, number} or null if the line doesn't match.
fn parseSchwabTitle(line: []const u8) ?struct { name: []const u8, number: []const u8 } {
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
const prefix = "Positions for account ";
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
const rest = stripped[prefix.len..];
// Find "..." which separates name from account number
const dots_idx = std.mem.indexOf(u8, rest, "...") orelse return null;
const name = std.mem.trimRight(u8, rest[0..dots_idx], &.{' '});
// Account number: after "..." until " as of" or end
const after_dots = rest[dots_idx + 3 ..];
const as_of_idx = std.mem.indexOf(u8, after_dots, " as of") orelse after_dots.len;
const number = std.mem.trim(u8, after_dots[0..as_of_idx], &.{' '});
return .{ .name = name, .number = number };
}
pub fn parseSchwabCsv(allocator: std.mem.Allocator, data: []const u8) !struct { positions: []BrokeragePosition, account_name: []const u8, account_number: []const u8 } {
var positions = std.ArrayList(BrokeragePosition).empty;
errdefer positions.deinit(allocator);
var lines = std.mem.splitScalar(u8, data, '\n');
// Line 1: title with account name and number
const title_line = lines.next() orelse return error.EmptyFile;
const title = parseSchwabTitle(title_line) orelse return error.UnexpectedHeader;
// Line 2: blank (skip)
_ = lines.next();
// Line 3: header row (skip)
_ = lines.next();
// Data rows
while (lines.next()) |line| {
const trimmed = std.mem.trimRight(u8, line, &.{ '\r', ' ' });
if (trimmed.len == 0) continue;
var cols: [schwab_expected_columns][]const u8 = undefined;
const col_count = splitSchwabCsvLine(trimmed, &cols);
if (col_count < schwab_expected_columns) continue;
const symbol = cols[SchwabCol.symbol];
if (symbol.len == 0) continue;
if (std.mem.eql(u8, symbol, "Positions Total")) continue;
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments");
try positions.append(allocator, .{
.account_number = title.number,
.account_name = title.name,
.symbol = symbol,
.description = if (col_count > 1) cols[1] else "",
.quantity = if (is_cash) null else parseDollarAmount(cols[SchwabCol.quantity]),
.current_value = parseDollarAmount(cols[SchwabCol.market_value]),
.cost_basis = if (is_cash) null else parseDollarAmount(cols[SchwabCol.cost_basis]),
.is_cash = is_cash,
});
}
return .{
.positions = try positions.toOwnedSlice(allocator),
.account_name = title.name,
.account_number = title.number,
};
}
// ── Schwab summary parser ───────────────────────────────────
//
// Parses the account summary paste from Schwab's web interface.
// The expected format is repeating blocks of 2-3 lines per account:
//
// Account Name
// Account number ending in NNN ...NNN
// Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
//
// Limitations:
//
// 1. NOT a CSV parser — parses freeform text pasted from the Schwab UI.
//
// 2. Identifies account blocks by the "Account number ending in" line.
// The account name is the non-empty line immediately before it.
//
// 3. The values line (cash, total, change, pct) is identified by finding
// dollar amounts. It tolerates missing or extra fields — it looks for
// the first two dollar amounts as cash and total value.
//
// 4. Skips summary lines like "Investment Total", "Day Change Total",
// and "Day Change Percent Total" which appear at the end of the paste.
//
// 5. Tolerant of partial pastes: if the user copies headers once but
// not on subsequent pastes, or includes extra blank lines, the parser
// still finds account blocks by the "Account number ending in" anchor.
//
// 6. The account number is extracted from "...NNN" at the end of the
// account number line (the last whitespace-separated token).
/// Account-level summary from a Schwab paste (no per-position detail).
pub const SchwabAccountSummary = struct {
account_name: []const u8,
account_number: []const u8,
cash: ?f64,
total_value: ?f64,
};
/// Parse Schwab account summary from pasted text.
/// All string fields in the returned summaries are slices into `data`.
/// Only the returned slice itself is heap-allocated (caller must free it).
pub fn parseSchwabSummary(allocator: std.mem.Allocator, data: []const u8) ![]SchwabAccountSummary {
var accounts = std.ArrayList(SchwabAccountSummary).empty;
errdefer accounts.deinit(allocator);
// Collect all lines, trimmed
var all_lines = std.ArrayList([]const u8).empty;
defer all_lines.deinit(allocator);
var line_iter = std.mem.splitScalar(u8, data, '\n');
while (line_iter.next()) |line| {
const trimmed = std.mem.trim(u8, line, &.{ '\r', ' ', '\t' });
try all_lines.append(allocator, trimmed);
}
const lines = all_lines.items;
// Scan for "Account number ending in" anchors
for (lines, 0..) |line, i| {
if (!std.mem.startsWith(u8, line, "Account number ending in")) continue;
// Extract account number: last token on the line (e.g. "...901" -> "901")
var acct_num: []const u8 = "";
var tok_iter = std.mem.tokenizeAny(u8, line, &.{ ' ', '\t' });
while (tok_iter.next()) |tok| {
acct_num = tok;
}
// Strip leading dots
while (acct_num.len > 0 and acct_num[0] == '.') {
acct_num = acct_num[1..];
}
// Account name: nearest non-empty line before the anchor
var acct_name: []const u8 = "";
if (i > 0) {
var j: usize = i - 1;
while (true) {
if (lines[j].len > 0 and
!std.mem.startsWith(u8, lines[j], "Account number") and
!std.mem.startsWith(u8, lines[j], "Investment Total") and
!std.mem.startsWith(u8, lines[j], "Day Change"))
{
acct_name = lines[j];
break;
}
if (j == 0) break;
j -= 1;
}
}
// Values line: look at lines after the anchor for dollar amounts.
// The format is "Type XXX $CASH $TOTAL +$CHANGE +PCT%"
// We want the first two dollar amounts (cash and total).
var cash: ?f64 = null;
var total: ?f64 = null;
if (i + 1 < lines.len) {
var dollar_values = std.ArrayList(f64).empty;
defer dollar_values.deinit(allocator);
var val_iter = std.mem.tokenizeAny(u8, lines[i + 1], &.{ ' ', '\t' });
while (val_iter.next()) |tok| {
if (parseDollarAmount(tok)) |v| {
dollar_values.append(allocator, v) catch {};
}
}
if (dollar_values.items.len >= 2) {
cash = dollar_values.items[0];
total = dollar_values.items[1];
} else if (dollar_values.items.len == 1) {
total = dollar_values.items[0];
}
}
try accounts.append(allocator, .{
.account_name = acct_name,
.account_number = acct_num,
.cash = cash,
.total_value = total,
});
}
if (accounts.items.len == 0) return error.NoAccountsFound;
return accounts.toOwnedSlice(allocator);
}
/// Account-level comparison result for Schwab summary audit.
pub const SchwabAccountComparison = struct {
account_name: []const u8,
schwab_name: []const u8,
account_number: []const u8,
portfolio_cash: f64,
schwab_cash: ?f64,
cash_delta: ?f64,
portfolio_total: f64,
schwab_total: ?f64,
total_delta: ?f64,
has_discrepancy: bool,
};
/// Compare Schwab summary against portfolio.srf account totals.
pub fn compareSchwabSummary(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
schwab_accounts: []const SchwabAccountSummary,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
) ![]SchwabAccountComparison {
var results = std.ArrayList(SchwabAccountComparison).empty;
errdefer results.deinit(allocator);
for (schwab_accounts) |sa| {
const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number);
var pf_cash: f64 = 0;
var pf_total: f64 = 0;
if (portfolio_acct) |pa| {
pf_cash = portfolio.cashForAccount(pa);
pf_total = portfolio.totalForAccount(allocator, pa, prices);
}
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
const total_delta = if (sa.total_value) |st| st - pf_total else null;
const cash_ok = if (cash_delta) |d| @abs(d) < 1.0 else true;
const total_ok = if (total_delta) |d| @abs(d) < 1.0 else true;
try results.append(allocator, .{
.account_name = portfolio_acct orelse "",
.schwab_name = sa.account_name,
.account_number = sa.account_number,
.portfolio_cash = pf_cash,
.schwab_cash = sa.cash,
.cash_delta = cash_delta,
.portfolio_total = pf_total,
.schwab_total = sa.total_value,
.total_delta = total_delta,
.has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
});
}
return results.toOwnedSlice(allocator);
}
fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void {
try cli.setBold(out, color);
try out.print("\nSchwab Account Audit", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" (brokerage is source of truth)\n", .{});
try cli.reset(out, color);
try out.print("========================================\n\n", .{});
// Column headers
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:<24} {s:>14} {s:>14} {s:>14} {s:>14}\n", .{
"Account", "PF Cash", "BR Cash", "PF Total", "BR Total",
});
try cli.reset(out, color);
var grand_pf: f64 = 0;
var grand_br: f64 = 0;
var discrepancy_count: usize = 0;
for (results) |r| {
const label = if (r.account_name.len > 0) r.account_name else r.schwab_name;
var pf_cash_buf: [24]u8 = undefined;
var br_cash_buf: [24]u8 = undefined;
var pf_total_buf: [24]u8 = undefined;
var br_total_buf: [24]u8 = undefined;
const br_cash_str = if (r.schwab_cash) |c|
fmt.fmtMoneyAbs(&br_cash_buf, c)
else
"--";
const br_total_str = if (r.schwab_total) |t|
fmt.fmtMoneyAbs(&br_total_buf, t)
else
"--";
const cash_ok = if (r.cash_delta) |d| @abs(d) < 1.0 else true;
const total_ok = if (r.total_delta) |d| @abs(d) < 1.0 else true;
const is_unmapped = r.account_name.len == 0;
const is_real_mismatch = !cash_ok or is_unmapped;
if (is_real_mismatch) discrepancy_count += 1;
// Account label
try out.print(" ", .{});
if (is_unmapped) {
try cli.setFg(out, color, cli.CLR_WARNING);
}
try out.print("{s:<24}", .{label});
if (is_unmapped) try cli.reset(out, color);
// PF Cash — colored if mismatched (brokerage is truth)
try out.print(" ", .{});
if (!cash_ok) {
if (r.cash_delta.? > 0)
try cli.setFg(out, color, cli.CLR_NEGATIVE)
else
try cli.setFg(out, color, cli.CLR_POSITIVE);
}
try out.print("{s:>14}", .{fmt.fmtMoneyAbs(&pf_cash_buf, r.portfolio_cash)});
if (!cash_ok) try cli.reset(out, color);
// BR Cash
try out.print(" {s:>14}", .{br_cash_str});
// PF Total — colored if not just stale prices
try out.print(" ", .{});
if (!total_ok and !cash_ok) {
if (r.total_delta.? > 0)
try cli.setFg(out, color, cli.CLR_NEGATIVE)
else
try cli.setFg(out, color, cli.CLR_POSITIVE);
}
try out.print("{s:>14}", .{fmt.fmtMoneyAbs(&pf_total_buf, r.portfolio_total)});
if (!total_ok and !cash_ok) try cli.reset(out, color);
// BR Total
try out.print(" {s:>14}", .{br_total_str});
// Status
if (is_unmapped) {
try cli.setFg(out, color, cli.CLR_WARNING);
try out.print(" Unmapped", .{});
try cli.reset(out, color);
} else if (!cash_ok) {
var delta_buf: [24]u8 = undefined;
const d = r.cash_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.setFg(out, color, cli.CLR_WARNING);
try out.print(" Cash {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) });
try cli.reset(out, color);
} else if (!total_ok) {
var delta_buf: [24]u8 = undefined;
const d = r.total_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Value {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) });
try cli.reset(out, color);
}
try out.print("\n", .{});
grand_pf += r.portfolio_total;
if (r.schwab_total) |t| grand_br += t;
}
// Grand totals
try out.print("\n", .{});
var pf_grand_buf: [24]u8 = undefined;
var br_grand_buf: [24]u8 = undefined;
var grand_delta_buf: [24]u8 = undefined;
const grand_delta = grand_br - grand_pf;
try cli.setBold(out, color);
try out.print(" Total: portfolio {s} schwab {s}", .{
fmt.fmtMoneyAbs(&pf_grand_buf, grand_pf),
fmt.fmtMoneyAbs(&br_grand_buf, grand_br),
});
try cli.reset(out, color);
if (@abs(grand_delta) < 1.0) {
// no delta
} else {
const sign: []const u8 = if (grand_delta >= 0) "+" else "-";
if (grand_delta >= 0) {
try cli.setFg(out, color, cli.CLR_NEGATIVE);
} else {
try cli.setFg(out, color, cli.CLR_POSITIVE);
}
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} {s} — drill down with: zfin audit --schwab <account.csv>\n", .{
discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"),
});
try cli.reset(out, color);
}
try out.print("\n", .{});
}
// ── 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_price: ?f64,
brokerage_price: ?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) {
const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null;
try comparisons.append(allocator, .{
.symbol = bp.symbol,
.portfolio_shares = 0,
.brokerage_shares = bp.quantity,
.portfolio_price = null,
.brokerage_price = br_price,
.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;
var pf_price: ?f64 = null;
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);
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))
continue;
pf_shares = pos.shares;
const price = prices.get(pos.symbol) orelse pos.avg_cost;
pf_price = price * pos.price_ratio;
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, {});
}
}
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;
const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null;
try comparisons.append(allocator, .{
.symbol = bp.symbol,
.portfolio_shares = pf_shares,
.brokerage_shares = bp.quantity,
.portfolio_price = pf_price,
.brokerage_price = br_price,
.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_price = price * pos.price_ratio,
.brokerage_price = null,
.portfolio_value = mv,
.brokerage_value = null,
.shares_delta = null,
.value_delta = null,
.is_cash = false,
.only_in_brokerage = false,
.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, .{
.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", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" (brokerage is source of truth)\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:>10} {s:>10} {s}\n", .{
"Symbol", "PF Shares", "BR Shares", "PF Price", "BR Price", "",
});
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_price_buf: [16]u8 = undefined;
var br_price_buf: [16]u8 = undefined;
// Classify this row
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage;
const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= 1.0 else false);
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch;
if (is_real_mismatch) discrepancy_count += 1;
// Format share strings
const pf_shares_str: []const u8 = if (cmp.is_cash)
(fmt.fmtMoneyAbs(&pf_shares_buf, cmp.portfolio_value))
else
std.fmt.bufPrint(&pf_shares_buf, "{d:.3}", .{cmp.portfolio_shares}) catch "?";
const br_shares_str: []const u8 = if (cmp.is_cash)
(if (cmp.brokerage_value) |v| fmt.fmtMoneyAbs(&br_shares_buf, v) else "--")
else if (cmp.brokerage_shares) |s|
(std.fmt.bufPrint(&br_shares_buf, "{d:.3}", .{s}) catch "?")
else
"--";
const pf_price_str: []const u8 = if (cmp.is_cash)
""
else if (cmp.portfolio_price) |p|
(std.fmt.bufPrint(&pf_price_buf, "{d:.2}", .{p}) catch "?")
else
"--";
const br_price_str: []const u8 = if (cmp.is_cash)
""
else if (cmp.brokerage_price) |p|
(std.fmt.bufPrint(&br_price_buf, "{d:.2}", .{p}) catch "?")
else
"--";
// Determine PF Shares color (relative to brokerage)
const shares_color: enum { normal, positive, negative, warning } = blk: {
if (cmp.only_in_portfolio) break :blk .warning;
if (cmp.is_cash and is_cash_mismatch) {
break :blk if (cmp.value_delta.? > 0) .negative else .positive;
}
if (shares_ok) break :blk .normal;
if (cmp.shares_delta) |d| {
break :blk if (d > 0) .negative else .positive;
}
break :blk .normal;
};
// Determine status text
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";
if (is_cash_mismatch) {
if (cmp.value_delta) |d| {
var delta_buf: [24]u8 = undefined;
const sign: []const u8 = if (d >= 0) "+" else "-";
break :blk std.fmt.bufPrint(&status_buf, "Cash {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }) catch "Cash mismatch";
}
}
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";
}
}
// Shares match — show value delta (stale price) if any, muted
if (cmp.value_delta) |d| {
if (@abs(d) >= 1.0) {
var delta_buf: [24]u8 = undefined;
const sign: []const u8 = if (d >= 0) "+" else "-";
break :blk std.fmt.bufPrint(&status_buf, "Value {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }) catch "";
}
}
break :blk "";
};
// Status color: real mismatches in warning, stale values muted, ok is blank
const status_color: enum { warning, muted, none } = blk: {
if (is_real_mismatch) break :blk .warning;
if (status.len > 0) break :blk .muted;
break :blk .none;
};
// Print symbol
try out.print(" {s:<24} ", .{cmp.symbol});
// Print PF Shares with color
switch (shares_color) {
.positive => try cli.setFg(out, color, cli.CLR_POSITIVE),
.negative => try cli.setFg(out, color, cli.CLR_NEGATIVE),
.warning => try cli.setFg(out, color, cli.CLR_WARNING),
.normal => {},
}
try out.print("{s:>12}", .{pf_shares_str});
if (shares_color != .normal) try cli.reset(out, color);
// Print BR Shares, prices
try out.print(" {s:>12} {s:>10} {s:>10} ", .{ br_shares_str, pf_price_str, br_price_str });
// Print status
switch (status_color) {
.warning => try cli.setFg(out, color, cli.CLR_WARNING),
.muted => try cli.setFg(out, color, cli.CLR_MUTED),
.none => {},
}
try out.print("{s}\n", .{status});
if (status_color != .none) 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:>10} {s:>10} ", .{
"", "", "", fmt.fmtMoneyAbs(&pf_total_buf, acct.portfolio_total), fmt.fmtMoneyAbs(&br_total_buf, acct.brokerage_total),
});
try cli.reset(out, color);
const delta = acct.total_delta;
if (@abs(delta) < 1.0) {
try out.print("\n", .{});
} else {
const sign: []const u8 = if (delta >= 0) "+" else "-";
if (delta >= 0) {
try cli.setFg(out, color, cli.CLR_NEGATIVE);
} else {
try cli.setFg(out, color, cli.CLR_POSITIVE);
}
try out.print("Delta {s}{s}\n", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(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) {
// no delta text needed
} else {
const sign: []const u8 = if (grand_delta >= 0) "+" else "-";
if (grand_delta >= 0) {
try cli.setFg(out, color, cli.CLR_NEGATIVE);
} else {
try cli.setFg(out, color, cli.CLR_POSITIVE);
}
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} {s} to investigate\n", .{ discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches") });
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 {
var fidelity_csv: ?[]const u8 = null;
var schwab_csv: ?[]const u8 = null;
var schwab_summary = false;
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], "--schwab") and i + 1 < args.len) {
i += 1;
schwab_csv = args[i];
} else if (std.mem.eql(u8, args[i], "--schwab-summary")) {
schwab_summary = true;
} 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 and schwab_csv == null and !schwab_summary) {
try cli.stderrPrint(
\\Usage: zfin audit [options] [-p <portfolio.srf>]
\\
\\ --fidelity <csv> Fidelity positions CSV export
\\ --schwab <csv> Schwab per-account positions CSV export
\\ --schwab-summary Schwab account summary (paste to stdin, Ctrl+D to end)
\\ -p, --portfolio Portfolio file (default: portfolio.srf)
\\
);
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
var account_map = svc.loadAccountMap(portfolio_path) orelse {
try cli.stderrPrint("Error: Cannot read/parse accounts.srf (needed for account number mapping)\n");
return;
};
defer account_map.deinit();
// Build cached prices (shared by all audit modes)
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);
}
}
for (portfolio.lots) |lot| {
if (lot.price) |p| {
if (!prices.contains(lot.priceSymbol())) {
try prices.put(lot.priceSymbol(), p * lot.price_ratio);
}
}
}
}
// Schwab summary from stdin
if (schwab_summary) {
try cli.stderrPrint("Paste Schwab account summary, then press Ctrl+D:\n");
const stdin_data = std.fs.File.stdin().readToEndAlloc(allocator, 1024 * 1024) catch {
try cli.stderrPrint("Error: Cannot read stdin\n");
return;
};
defer allocator.free(stdin_data);
const schwab_accounts = parseSchwabSummary(allocator, stdin_data) catch {
try cli.stderrPrint("Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
return;
};
defer allocator.free(schwab_accounts);
const results = try compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices);
defer allocator.free(results);
try displaySchwabResults(results, color, out);
}
// Fidelity CSV
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);
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);
}
// Schwab per-account CSV
if (schwab_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 parsed = parseSchwabCsv(allocator, csv_data) catch {
try cli.stderrPrint("Error: Cannot parse Schwab CSV (unexpected format?)\n");
return;
};
defer allocator.free(parsed.positions);
const results = try compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", 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 treats $1.00 price+cost as cash" {
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,FDRXX,FID GOV CASH RESERVE,8500,$1.00,,$8500.00,,,,,10%,$8500.00,$1.00,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("FDRXX", positions[0].symbol);
try std.testing.expect(positions[0].is_cash);
try std.testing.expect(positions[0].quantity == null);
try std.testing.expectApproxEqAbs(@as(f64, 8500.00), positions[0].current_value.?, 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);
}
test "parseSchwabSummary basic" {
const data =
\\Emil Roth
\\Account number ending in 901 ...901
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
\\Inherited IRA
\\Account number ending in 503 ...503
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
;
const allocator = std.testing.allocator;
const accounts = try parseSchwabSummary(allocator, data);
defer allocator.free(accounts);
try std.testing.expectEqual(@as(usize, 2), accounts.len);
try std.testing.expectEqualStrings("Emil Roth", accounts[0].account_name);
try std.testing.expectEqualStrings("901", accounts[0].account_number);
try std.testing.expectApproxEqAbs(@as(f64, 46.44), accounts[0].cash.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 227058.15), accounts[0].total_value.?, 0.01);
try std.testing.expectEqualStrings("Inherited IRA", accounts[1].account_name);
try std.testing.expectEqualStrings("503", accounts[1].account_number);
try std.testing.expectApproxEqAbs(@as(f64, 2461.82), accounts[1].cash.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 167544.08), accounts[1].total_value.?, 0.01);
}
test "parseSchwabSummary tolerates missing headers and extra blank lines" {
const data =
\\
\\Joint trust
\\Account number ending in 716 ...716
\\Type Brokerage $8,271.12 $849,087.12 +$20,488.80 +2.47%
\\
\\Tax Loss
\\Account number ending in 311 ...311
\\$4,654.15 $488,481.18 +$1,686.91 +0.35%
;
const allocator = std.testing.allocator;
const accounts = try parseSchwabSummary(allocator, data);
defer allocator.free(accounts);
try std.testing.expectEqual(@as(usize, 2), accounts.len);
try std.testing.expectEqualStrings("Joint trust", accounts[0].account_name);
try std.testing.expectEqualStrings("716", accounts[0].account_number);
// Second account has no "Type" prefix — parser still finds dollar amounts
try std.testing.expectEqualStrings("Tax Loss", accounts[1].account_name);
try std.testing.expectApproxEqAbs(@as(f64, 4654.15), accounts[1].cash.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 488481.18), accounts[1].total_value.?, 0.01);
}
test "parseSchwabSummary skips summary footer" {
const data =
\\Mom
\\Account number ending in 152 ...152
\\Type Brokerage $3,492.85 $161,676.14 +$749.40 +0.47%
\\Investment Total
\\$22,070.35
\\$4,338,116.38
\\Day Change Total
\\+$31,633.86
;
const allocator = std.testing.allocator;
const accounts = try parseSchwabSummary(allocator, data);
defer allocator.free(accounts);
try std.testing.expectEqual(@as(usize, 1), accounts.len);
try std.testing.expectEqualStrings("Mom", accounts[0].account_name);
}
test "parseSchwabSummary no accounts" {
const allocator = std.testing.allocator;
const result = parseSchwabSummary(allocator, "some random text\nno accounts here\n");
try std.testing.expectError(error.NoAccountsFound, result);
}
test "parseSchwabTitle" {
const t1 = parseSchwabTitle("\"Positions for account Joint trust ...716 as of 10:47 AM ET, 2026/04/10\"");
try std.testing.expect(t1 != null);
try std.testing.expectEqualStrings("Joint trust", t1.?.name);
try std.testing.expectEqualStrings("716", t1.?.number);
const t2 = parseSchwabTitle("\"Positions for account Emil IRA ...118 as of 3:00 PM ET, 2026/04/10\"");
try std.testing.expect(t2 != null);
try std.testing.expectEqualStrings("Emil IRA", t2.?.name);
try std.testing.expectEqualStrings("118", t2.?.number);
try std.testing.expect(parseSchwabTitle("some random text") == null);
}
test "splitSchwabCsvLine" {
var cols: [schwab_expected_columns][]const u8 = undefined;
const n = splitSchwabCsvLine("\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",", &cols);
try std.testing.expectEqual(@as(usize, 17), n);
try std.testing.expectEqualStrings("AMZN", cols[0]);
try std.testing.expectEqualStrings("AMAZON.COM INC", cols[1]);
try std.testing.expectEqualStrings("1,488", cols[5]);
try std.testing.expectEqualStrings("$355,941.50", cols[8]);
try std.testing.expectEqualStrings("Equity", cols[16]);
}
test "parseSchwabCsv basic" {
const csv =
"\"Positions for account Joint trust ...716 as of 10:47 AM ET, 2026/04/10\"\n" ++
"\n" ++
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n" ++
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$8,271.12\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0.97%\",\"Cash and Money Market\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$856,805.99\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
const allocator = std.testing.allocator;
const parsed = try parseSchwabCsv(allocator, csv);
defer allocator.free(parsed.positions);
try std.testing.expectEqualStrings("Joint trust", parsed.account_name);
try std.testing.expectEqualStrings("716", parsed.account_number);
try std.testing.expectEqual(@as(usize, 2), parsed.positions.len);
// Stock position
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
try std.testing.expect(!parsed.positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 1488), parsed.positions[0].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), parsed.positions[0].current_value.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), parsed.positions[0].cost_basis.?, 0.01);
// Cash
try std.testing.expectEqualStrings("Cash & Cash Investments", parsed.positions[1].symbol);
try std.testing.expect(parsed.positions[1].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), parsed.positions[1].current_value.?, 0.01);
try std.testing.expect(parsed.positions[1].quantity == null);
}