add schwab summary and account level audit
This commit is contained in:
parent
9337c198f4
commit
6fb582e3da
2 changed files with 708 additions and 36 deletions
|
|
@ -180,6 +180,464 @@ fn parseDollarAmount(raw: []const u8) ?f64 {
|
||||||
return if (negative) -val else val;
|
return if (negative) -val else val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
|
||||||
|
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 => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (summary)\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} {s}\n", .{
|
||||||
|
"Account", "PF Cash", "BR Cash", "PF Total", "BR Total", "Status",
|
||||||
|
});
|
||||||
|
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
|
||||||
|
"--";
|
||||||
|
|
||||||
|
var status_buf: [64]u8 = undefined;
|
||||||
|
const status: []const u8 = blk: {
|
||||||
|
if (r.account_name.len == 0) break :blk "Unmapped";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (cash_ok and total_ok) break :blk "ok";
|
||||||
|
|
||||||
|
if (!cash_ok) {
|
||||||
|
if (r.cash_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 (!total_ok) {
|
||||||
|
if (r.total_delta) |d| {
|
||||||
|
var delta_buf: [24]u8 = undefined;
|
||||||
|
const sign: []const u8 = if (d >= 0) "+" else "-";
|
||||||
|
break :blk std.fmt.bufPrint(&status_buf, "Total {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }) catch "Total mismatch";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break :blk "Mismatch";
|
||||||
|
};
|
||||||
|
|
||||||
|
const is_ok = std.mem.eql(u8, status, "ok");
|
||||||
|
if (!is_ok) discrepancy_count += 1;
|
||||||
|
|
||||||
|
try out.print(" {s:<24} {s:>14} {s:>14} {s:>14} {s:>14} ", .{
|
||||||
|
label,
|
||||||
|
fmt.fmtMoneyAbs(&pf_cash_buf, r.portfolio_cash),
|
||||||
|
br_cash_str,
|
||||||
|
fmt.fmtMoneyAbs(&pf_total_buf, r.portfolio_total),
|
||||||
|
br_total_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);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 — drill down with: zfin audit --schwab <account.csv>\n", .{discrepancy_count});
|
||||||
|
try cli.reset(out, color);
|
||||||
|
} else {
|
||||||
|
try cli.setFg(out, color, cli.CLR_POSITIVE);
|
||||||
|
try out.print(" All accounts match\n", .{});
|
||||||
|
try cli.reset(out, color);
|
||||||
|
}
|
||||||
|
try out.print("\n", .{});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Audit logic ─────────────────────────────────────────────
|
// ── Audit logic ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// Comparison result for a single symbol within an account.
|
/// Comparison result for a single symbol within an account.
|
||||||
|
|
@ -187,6 +645,8 @@ pub const SymbolComparison = struct {
|
||||||
symbol: []const u8,
|
symbol: []const u8,
|
||||||
portfolio_shares: f64,
|
portfolio_shares: f64,
|
||||||
brokerage_shares: ?f64,
|
brokerage_shares: ?f64,
|
||||||
|
portfolio_price: ?f64,
|
||||||
|
brokerage_price: ?f64,
|
||||||
portfolio_value: f64,
|
portfolio_value: f64,
|
||||||
brokerage_value: ?f64,
|
brokerage_value: ?f64,
|
||||||
shares_delta: ?f64,
|
shares_delta: ?f64,
|
||||||
|
|
@ -263,10 +723,13 @@ pub fn compareAccounts(
|
||||||
brokerage_total += bp_value;
|
brokerage_total += bp_value;
|
||||||
|
|
||||||
if (portfolio_acct_name == null) {
|
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, .{
|
try comparisons.append(allocator, .{
|
||||||
.symbol = bp.symbol,
|
.symbol = bp.symbol,
|
||||||
.portfolio_shares = 0,
|
.portfolio_shares = 0,
|
||||||
.brokerage_shares = bp.quantity,
|
.brokerage_shares = bp.quantity,
|
||||||
|
.portfolio_price = null,
|
||||||
|
.brokerage_price = br_price,
|
||||||
.portfolio_value = 0,
|
.portfolio_value = 0,
|
||||||
.brokerage_value = bp.current_value,
|
.brokerage_value = bp.current_value,
|
||||||
.shares_delta = if (bp.quantity) |q| q else null,
|
.shares_delta = if (bp.quantity) |q| q else null,
|
||||||
|
|
@ -282,6 +745,7 @@ pub fn compareAccounts(
|
||||||
// Sum portfolio lots for this symbol+account
|
// Sum portfolio lots for this symbol+account
|
||||||
var pf_shares: f64 = 0;
|
var pf_shares: f64 = 0;
|
||||||
var pf_value: f64 = 0;
|
var pf_value: f64 = 0;
|
||||||
|
var pf_price: ?f64 = null;
|
||||||
|
|
||||||
if (bp.is_cash) {
|
if (bp.is_cash) {
|
||||||
pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
|
pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
|
||||||
|
|
@ -296,6 +760,7 @@ pub fn compareAccounts(
|
||||||
continue;
|
continue;
|
||||||
pf_shares = pos.shares;
|
pf_shares = pos.shares;
|
||||||
const price = prices.get(pos.symbol) orelse pos.avg_cost;
|
const price = prices.get(pos.symbol) orelse pos.avg_cost;
|
||||||
|
pf_price = price * pos.price_ratio;
|
||||||
pf_value = pos.shares * price * pos.price_ratio;
|
pf_value = pos.shares * price * pos.price_ratio;
|
||||||
try matched_symbols.put(pos.symbol, {});
|
try matched_symbols.put(pos.symbol, {});
|
||||||
try matched_symbols.put(pos.lot_symbol, {});
|
try matched_symbols.put(pos.lot_symbol, {});
|
||||||
|
|
@ -313,10 +778,14 @@ pub fn compareAccounts(
|
||||||
|
|
||||||
if (!shares_match or !value_match) has_discrepancies = 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, .{
|
try comparisons.append(allocator, .{
|
||||||
.symbol = bp.symbol,
|
.symbol = bp.symbol,
|
||||||
.portfolio_shares = pf_shares,
|
.portfolio_shares = pf_shares,
|
||||||
.brokerage_shares = bp.quantity,
|
.brokerage_shares = bp.quantity,
|
||||||
|
.portfolio_price = pf_price,
|
||||||
|
.brokerage_price = br_price,
|
||||||
.portfolio_value = pf_value,
|
.portfolio_value = pf_value,
|
||||||
.brokerage_value = bp.current_value,
|
.brokerage_value = bp.current_value,
|
||||||
.shares_delta = shares_delta,
|
.shares_delta = shares_delta,
|
||||||
|
|
@ -347,6 +816,8 @@ pub fn compareAccounts(
|
||||||
.symbol = pos.symbol,
|
.symbol = pos.symbol,
|
||||||
.portfolio_shares = pos.shares,
|
.portfolio_shares = pos.shares,
|
||||||
.brokerage_shares = null,
|
.brokerage_shares = null,
|
||||||
|
.portfolio_price = price * pos.price_ratio,
|
||||||
|
.brokerage_price = null,
|
||||||
.portfolio_value = mv,
|
.portfolio_value = mv,
|
||||||
.brokerage_value = null,
|
.brokerage_value = null,
|
||||||
.shares_delta = null,
|
.shares_delta = null,
|
||||||
|
|
@ -403,16 +874,16 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.
|
||||||
|
|
||||||
// Column headers
|
// Column headers
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
try out.print(" {s:<24} {s:>12} {s:>12} {s:>14} {s:>14} {s}\n", .{
|
try out.print(" {s:<24} {s:>12} {s:>12} {s:>10} {s:>10} {s}\n", .{
|
||||||
"Symbol", "PF Shares", "BR Shares", "PF Value", "BR Value", "Status",
|
"Symbol", "PF Shares", "BR Shares", "PF Price", "BR Price", "Status",
|
||||||
});
|
});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
|
|
||||||
for (acct.comparisons) |cmp| {
|
for (acct.comparisons) |cmp| {
|
||||||
var pf_shares_buf: [16]u8 = undefined;
|
var pf_shares_buf: [16]u8 = undefined;
|
||||||
var br_shares_buf: [16]u8 = undefined;
|
var br_shares_buf: [16]u8 = undefined;
|
||||||
var pf_val_buf: [24]u8 = undefined;
|
var pf_price_buf: [16]u8 = undefined;
|
||||||
var br_val_buf: [24]u8 = undefined;
|
var br_price_buf: [16]u8 = undefined;
|
||||||
|
|
||||||
const pf_shares_str = if (cmp.is_cash)
|
const pf_shares_str = if (cmp.is_cash)
|
||||||
"--"
|
"--"
|
||||||
|
|
@ -426,10 +897,17 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.
|
||||||
else
|
else
|
||||||
"--";
|
"--";
|
||||||
|
|
||||||
const pf_val_str = fmt.fmtMoneyAbs(&pf_val_buf, cmp.portfolio_value);
|
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_val_str = if (cmp.brokerage_value) |v|
|
const br_price_str: []const u8 = if (cmp.is_cash)
|
||||||
fmt.fmtMoneyAbs(&br_val_buf, @abs(v))
|
"--"
|
||||||
|
else if (cmp.brokerage_price) |p|
|
||||||
|
(std.fmt.bufPrint(&br_price_buf, "{d:.2}", .{p}) catch "?")
|
||||||
else
|
else
|
||||||
"--";
|
"--";
|
||||||
|
|
||||||
|
|
@ -465,8 +943,8 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.
|
||||||
const is_ok = std.mem.eql(u8, status, "ok");
|
const is_ok = std.mem.eql(u8, status, "ok");
|
||||||
if (!is_ok) discrepancy_count += 1;
|
if (!is_ok) discrepancy_count += 1;
|
||||||
|
|
||||||
try out.print(" {s:<24} {s:>12} {s:>12} {s:>14} {s:>14} ", .{
|
try out.print(" {s:<24} {s:>12} {s:>12} {s:>10} {s:>10} ", .{
|
||||||
cmp.symbol, pf_shares_str, br_shares_str, pf_val_str, br_val_str,
|
cmp.symbol, pf_shares_str, br_shares_str, pf_price_str, br_price_str,
|
||||||
});
|
});
|
||||||
if (is_ok) {
|
if (is_ok) {
|
||||||
try cli.setFg(out, color, cli.CLR_POSITIVE);
|
try cli.setFg(out, color, cli.CLR_POSITIVE);
|
||||||
|
|
@ -482,7 +960,7 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.
|
||||||
var br_total_buf: [24]u8 = undefined;
|
var br_total_buf: [24]u8 = undefined;
|
||||||
var delta_buf: [24]u8 = undefined;
|
var delta_buf: [24]u8 = undefined;
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
try out.print(" {s:<24} {s:>12} {s:>12} {s:>14} {s:>14} ", .{
|
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),
|
"", "", "", fmt.fmtMoneyAbs(&pf_total_buf, acct.portfolio_total), fmt.fmtMoneyAbs(&br_total_buf, acct.brokerage_total),
|
||||||
});
|
});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
|
|
@ -541,8 +1019,9 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.
|
||||||
// ── CLI entry point ─────────────────────────────────────────
|
// ── CLI entry point ─────────────────────────────────────────
|
||||||
|
|
||||||
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const []const u8, color: bool, out: *std.Io.Writer) !void {
|
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const []const u8, color: bool, out: *std.Io.Writer) !void {
|
||||||
// Parse flags: --fidelity <csv> [--portfolio <file>]
|
|
||||||
var fidelity_csv: ?[]const u8 = null;
|
var fidelity_csv: ?[]const u8 = null;
|
||||||
|
var schwab_csv: ?[]const u8 = null;
|
||||||
|
var schwab_summary = false;
|
||||||
var portfolio_path: []const u8 = "portfolio.srf";
|
var portfolio_path: []const u8 = "portfolio.srf";
|
||||||
|
|
||||||
var i: usize = 0;
|
var i: usize = 0;
|
||||||
|
|
@ -550,6 +1029,11 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [
|
||||||
if (std.mem.eql(u8, args[i], "--fidelity") and i + 1 < args.len) {
|
if (std.mem.eql(u8, args[i], "--fidelity") and i + 1 < args.len) {
|
||||||
i += 1;
|
i += 1;
|
||||||
fidelity_csv = args[i];
|
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) {
|
} else if ((std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) and i + 1 < args.len) {
|
||||||
i += 1;
|
i += 1;
|
||||||
portfolio_path = args[i];
|
portfolio_path = args[i];
|
||||||
|
|
@ -558,8 +1042,16 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fidelity_csv == null) {
|
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
|
||||||
try cli.stderrPrint("Usage: zfin audit --fidelity <positions.csv> [-p <portfolio.srf>]\n");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -593,7 +1085,48 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [
|
||||||
};
|
};
|
||||||
defer account_map.deinit();
|
defer account_map.deinit();
|
||||||
|
|
||||||
// Load brokerage data
|
// 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| {
|
if (fidelity_csv) |csv_path| {
|
||||||
const csv_data = std.fs.cwd().readFileAlloc(allocator, csv_path, 10 * 1024 * 1024) catch {
|
const csv_data = std.fs.cwd().readFileAlloc(allocator, csv_path, 10 * 1024 * 1024) catch {
|
||||||
var msg_buf: [256]u8 = undefined;
|
var msg_buf: [256]u8 = undefined;
|
||||||
|
|
@ -609,28 +1142,6 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [
|
||||||
};
|
};
|
||||||
defer allocator.free(brokerage_positions);
|
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);
|
const results = try compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices);
|
||||||
defer {
|
defer {
|
||||||
for (results) |r| allocator.free(r.comparisons);
|
for (results) |r| allocator.free(r.comparisons);
|
||||||
|
|
@ -639,6 +1150,31 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [
|
||||||
|
|
||||||
try displayResults(results, color, out);
|
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 ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
@ -741,3 +1277,137 @@ test "parseFidelityCsv cash account type is not cash position" {
|
||||||
try std.testing.expect(!positions[0].is_cash);
|
try std.testing.expect(!positions[0].is_cash);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ const usage =
|
||||||
\\
|
\\
|
||||||
\\Audit command options:
|
\\Audit command options:
|
||||||
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
|
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
|
||||||
|
\\ --schwab <CSV> Schwab per-account positions CSV export
|
||||||
|
\\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin)
|
||||||
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf)
|
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf)
|
||||||
\\
|
\\
|
||||||
\\Analysis command:
|
\\Analysis command:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue