add audit command (fidelity only for now)

This commit is contained in:
Emil Lerch 2026-04-09 17:33:02 -07:00
parent d7a4aa6f63
commit f3f9a20824
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 893 additions and 1 deletions

View file

@ -38,6 +38,8 @@ pub const TaxType = enum {
pub const AccountTaxEntry = struct { pub const AccountTaxEntry = struct {
account: []const u8, account: []const u8,
tax_type: TaxType, tax_type: TaxType,
institution: ?[]const u8 = null,
account_number: ?[]const u8 = null,
}; };
/// Parsed account metadata. /// Parsed account metadata.
@ -48,6 +50,8 @@ pub const AccountMap = struct {
pub fn deinit(self: *AccountMap) void { pub fn deinit(self: *AccountMap) void {
for (self.entries) |e| { for (self.entries) |e| {
self.allocator.free(e.account); self.allocator.free(e.account);
if (e.institution) |s| self.allocator.free(s);
if (e.account_number) |s| self.allocator.free(s);
} }
self.allocator.free(self.entries); self.allocator.free(self.entries);
} }
@ -61,15 +65,42 @@ pub const AccountMap = struct {
} }
return "Unknown"; return "Unknown";
} }
/// Find the portfolio account name for a given institution + account number.
pub fn findByInstitutionAccount(self: AccountMap, institution: []const u8, account_number: []const u8) ?[]const u8 {
for (self.entries) |e| {
if (e.institution) |inst| {
if (e.account_number) |num| {
if (std.mem.eql(u8, inst, institution) and std.mem.eql(u8, num, account_number))
return e.account;
}
}
}
return null;
}
/// Return all entries matching a given institution.
pub fn entriesForInstitution(self: AccountMap, institution: []const u8) []const AccountTaxEntry {
var count: usize = 0;
for (self.entries) |e| {
if (e.institution) |inst| {
if (std.mem.eql(u8, inst, institution)) count += 1;
}
}
if (count == 0) return &.{};
return self.entries;
}
}; };
/// Parse an accounts.srf file into an AccountMap. /// Parse an accounts.srf file into an AccountMap.
/// Each record has: account::<NAME>,tax_type::<TYPE> /// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>]
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap { pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
var entries = std.ArrayList(AccountTaxEntry).empty; var entries = std.ArrayList(AccountTaxEntry).empty;
errdefer { errdefer {
for (entries.items) |e| { for (entries.items) |e| {
allocator.free(e.account); allocator.free(e.account);
if (e.institution) |s| allocator.free(s);
if (e.account_number) |s| allocator.free(s);
} }
entries.deinit(allocator); entries.deinit(allocator);
} }
@ -83,6 +114,8 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
try entries.append(allocator, .{ try entries.append(allocator, .{
.account = try allocator.dupe(u8, entry.account), .account = try allocator.dupe(u8, entry.account),
.tax_type = entry.tax_type, .tax_type = entry.tax_type,
.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null,
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
}); });
} }

743
src/commands/audit.zig Normal file
View file

@ -0,0 +1,743 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
const analysis = @import("../analytics/analysis.zig");
// Brokerage position (normalized from any source)
/// A single position row from a brokerage export, normalized to a common format.
pub const BrokeragePosition = struct {
account_number: []const u8,
account_name: []const u8,
symbol: []const u8,
description: []const u8,
quantity: ?f64,
current_value: ?f64,
cost_basis: ?f64,
is_cash: bool,
};
/// Parsed brokerage data grouped by account.
pub const BrokerageAccount = struct {
account_number: []const u8,
account_name: []const u8,
portfolio_account: ?[]const u8,
positions: []const BrokeragePosition,
total_value: f64,
cash_value: f64,
};
// Fidelity CSV parser
//
// Limitations of this CSV parser:
//
// 1. NOT a general-purpose CSV parser. It handles the specific format
// exported by Fidelity's "Download Positions" feature.
//
// 2. Does NOT handle quoted fields containing commas. Fidelity's export
// does not quote fields with commas in practice (description fields
// use spaces, not commas), but a truly compliant RFC 4180 parser would
// need to handle "field,with,commas" as a single value.
//
// 3. Does NOT handle escaped quotes ("" inside quoted fields).
//
// 4. Does NOT handle multi-line values (newlines inside quoted fields).
//
// 5. Assumes UTF-8 with optional BOM (which Fidelity includes).
//
// 6. Stops parsing at the first blank line, which separates position data
// from the Fidelity legal disclaimer footer.
//
// 7. Hardcodes the expected column layout. If Fidelity changes the CSV
// format (adds/removes/reorders columns), this parser will break.
// The header row is validated to catch this.
//
// 8. Dollar values like "$1,234.56" and "+$1,234.56" are stripped of
// $, commas, and leading +/- signs. Negative values wrapped in
// parentheses are NOT handled (Fidelity uses -$X.XX format).
//
// 9. Money market rows (symbol ending in **) are treated as cash.
//
// For a production-grade CSV parser, consider a library that handles
// RFC 4180 fully (quoted fields, escaping, multi-line values).
const fidelity_expected_columns = 16;
/// Column indices in the Fidelity CSV export.
/// Based on: Account Number, Account Name, Symbol, Description, Quantity,
/// Last Price, Last Price Change, Current Value, Today's Gain/Loss Dollar,
/// Today's Gain/Loss Percent, Total Gain/Loss Dollar, Total Gain/Loss Percent,
/// Percent Of Account, Cost Basis Total, Average Cost Basis, Type
const FidelityCol = struct {
const account_number = 0;
const account_name = 1;
const symbol = 2;
const description = 3;
const quantity = 4;
const current_value = 7;
const cost_basis_total = 13;
const type_col = 15;
};
/// Parse a Fidelity CSV positions export into BrokeragePosition slices.
/// All string fields in the returned positions are slices into `data`,
/// so the caller must keep `data` alive for as long as the positions are used.
/// Only the returned slice itself is heap-allocated (caller must free it).
pub fn parseFidelityCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition {
var positions = std.ArrayList(BrokeragePosition).empty;
errdefer positions.deinit(allocator);
// Skip UTF-8 BOM if present
var content = data;
if (content.len >= 3 and content[0] == 0xEF and content[1] == 0xBB and content[2] == 0xBF) {
content = content[3..];
}
var lines = std.mem.splitScalar(u8, content, '\n');
// Validate header row
const header_line = lines.next() orelse return error.EmptyFile;
const header_trimmed = std.mem.trimRight(u8, header_line, &.{ '\r', ' ' });
if (header_trimmed.len == 0) return error.EmptyFile;
if (!std.mem.startsWith(u8, header_trimmed, "Account Number")) {
return error.UnexpectedHeader;
}
// Parse data rows
while (lines.next()) |line| {
const trimmed = std.mem.trimRight(u8, line, &.{ '\r', ' ' });
if (trimmed.len == 0) break;
// Skip lines starting with " (disclaimer text)
if (trimmed[0] == '"') break;
var col_iter = std.mem.splitScalar(u8, trimmed, ',');
var cols: [fidelity_expected_columns][]const u8 = undefined;
var col_count: usize = 0;
while (col_iter.next()) |col| {
if (col_count < fidelity_expected_columns) {
cols[col_count] = col;
col_count += 1;
}
}
if (col_count < fidelity_expected_columns) continue;
const symbol_raw = std.mem.trim(u8, cols[FidelityCol.symbol], &.{ ' ', '"' });
if (symbol_raw.len == 0) continue;
// Money market funds (symbol ending in **) are cash positions.
// Note: Fidelity's Type column says "Cash" vs "Margin" to indicate
// the account settlement type, NOT that the security is cash.
// Only the ** suffix reliably identifies money market / cash holdings.
const is_cash = std.mem.endsWith(u8, symbol_raw, "**");
// Strip ** suffix from money market symbols for display
const symbol_clean = if (std.mem.endsWith(u8, symbol_raw, "**"))
symbol_raw[0 .. symbol_raw.len - 2]
else
symbol_raw;
try positions.append(allocator, .{
.account_number = std.mem.trim(u8, cols[FidelityCol.account_number], &.{ ' ', '"' }),
.account_name = std.mem.trim(u8, cols[FidelityCol.account_name], &.{ ' ', '"' }),
.symbol = symbol_clean,
.description = std.mem.trim(u8, cols[FidelityCol.description], &.{ ' ', '"' }),
.quantity = if (is_cash) null else parseDollarAmount(cols[FidelityCol.quantity]),
.current_value = parseDollarAmount(cols[FidelityCol.current_value]),
.cost_basis = if (is_cash) null else parseDollarAmount(cols[FidelityCol.cost_basis_total]),
.is_cash = is_cash,
});
}
return positions.toOwnedSlice(allocator);
}
/// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00".
/// Strips $, commas, and +/- prefix. Returns null for empty or unparseable values.
fn parseDollarAmount(raw: []const u8) ?f64 {
const trimmed = std.mem.trim(u8, raw, &.{ ' ', '"' });
if (trimmed.len == 0) return null;
// Strip leading +/- and $, remove commas
var buf: [64]u8 = undefined;
var pos: usize = 0;
var negative = false;
for (trimmed) |c| {
if (c == '-') {
negative = true;
} else if (c == '$' or c == '+' or c == ',') {
continue;
} else {
if (pos >= buf.len) return null;
buf[pos] = c;
pos += 1;
}
}
if (pos == 0) return null;
const val = std.fmt.parseFloat(f64, buf[0..pos]) catch return null;
return if (negative) -val else val;
}
// Audit logic
/// Comparison result for a single symbol within an account.
pub const SymbolComparison = struct {
symbol: []const u8,
portfolio_shares: f64,
brokerage_shares: ?f64,
portfolio_value: f64,
brokerage_value: ?f64,
shares_delta: ?f64,
value_delta: ?f64,
is_cash: bool,
only_in_brokerage: bool,
only_in_portfolio: bool,
};
/// Comparison result for a single account.
pub const AccountComparison = struct {
account_name: []const u8,
brokerage_name: []const u8,
account_number: []const u8,
comparisons: []const SymbolComparison,
portfolio_total: f64,
brokerage_total: f64,
total_delta: f64,
has_discrepancies: bool,
};
/// Build per-account comparisons between portfolio.srf and brokerage data.
pub fn compareAccounts(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
brokerage_positions: []const BrokeragePosition,
account_map: analysis.AccountMap,
institution: []const u8,
prices: std.StringHashMap(f64),
) ![]AccountComparison {
var results = std.ArrayList(AccountComparison).empty;
errdefer results.deinit(allocator);
// Group brokerage positions by account number
var brokerage_accounts = std.StringHashMap(std.ArrayList(BrokeragePosition)).init(allocator);
defer {
var it = brokerage_accounts.valueIterator();
while (it.next()) |v| v.deinit(allocator);
brokerage_accounts.deinit();
}
for (brokerage_positions) |bp| {
const entry = try brokerage_accounts.getOrPut(bp.account_number);
if (!entry.found_existing) {
entry.value_ptr.* = .empty;
}
try entry.value_ptr.append(allocator, bp);
}
// For each brokerage account, find the matching portfolio account and compare
var acct_iter = brokerage_accounts.iterator();
while (acct_iter.next()) |kv| {
const acct_num = kv.key_ptr.*;
const broker_positions = kv.value_ptr.items;
if (broker_positions.len == 0) continue;
const broker_name = broker_positions[0].account_name;
const portfolio_acct_name = account_map.findByInstitutionAccount(institution, acct_num);
var comparisons = std.ArrayList(SymbolComparison).empty;
errdefer comparisons.deinit(allocator);
var portfolio_total: f64 = 0;
var brokerage_total: f64 = 0;
var has_discrepancies = false;
// Track which portfolio symbols we've matched
var matched_symbols = std.StringHashMap(void).init(allocator);
defer matched_symbols.deinit();
// Compare each brokerage position against portfolio
for (broker_positions) |bp| {
const bp_value = bp.current_value orelse 0;
brokerage_total += bp_value;
if (portfolio_acct_name == null) {
try comparisons.append(allocator, .{
.symbol = bp.symbol,
.portfolio_shares = 0,
.brokerage_shares = bp.quantity,
.portfolio_value = 0,
.brokerage_value = bp.current_value,
.shares_delta = if (bp.quantity) |q| q else null,
.value_delta = bp.current_value,
.is_cash = bp.is_cash,
.only_in_brokerage = true,
.only_in_portfolio = false,
});
has_discrepancies = true;
continue;
}
// Sum portfolio lots for this symbol+account
var pf_shares: f64 = 0;
var pf_value: f64 = 0;
if (bp.is_cash) {
pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
pf_value = pf_shares;
} else {
const acct_positions = portfolio.positionsForAccount(allocator, portfolio_acct_name.?) catch &.{};
defer allocator.free(acct_positions);
for (acct_positions) |pos| {
if (!std.mem.eql(u8, pos.symbol, bp.symbol) and
!std.mem.eql(u8, pos.lot_symbol, bp.symbol))
continue;
pf_shares = pos.shares;
const price = prices.get(pos.symbol) orelse pos.avg_cost;
pf_value = pos.shares * price * pos.price_ratio;
try matched_symbols.put(pos.symbol, {});
try matched_symbols.put(pos.lot_symbol, {});
}
}
try matched_symbols.put(bp.symbol, {});
portfolio_total += pf_value;
const shares_delta = if (bp.quantity) |bq| bq - pf_shares else null;
const value_delta = if (bp.current_value) |bv| bv - pf_value else null;
const shares_match = if (shares_delta) |d| @abs(d) < 0.01 else true;
const value_match = if (value_delta) |d| @abs(d) < 1.0 else true;
if (!shares_match or !value_match) has_discrepancies = true;
try comparisons.append(allocator, .{
.symbol = bp.symbol,
.portfolio_shares = pf_shares,
.brokerage_shares = bp.quantity,
.portfolio_value = pf_value,
.brokerage_value = bp.current_value,
.shares_delta = shares_delta,
.value_delta = value_delta,
.is_cash = bp.is_cash,
.only_in_brokerage = pf_shares == 0 and pf_value == 0,
.only_in_portfolio = false,
});
}
// Find portfolio-only positions (in portfolio but not in brokerage)
if (portfolio_acct_name) |pa| {
const acct_positions = portfolio.positionsForAccount(allocator, pa) catch &.{};
defer allocator.free(acct_positions);
for (acct_positions) |pos| {
if (matched_symbols.contains(pos.symbol)) continue;
if (matched_symbols.contains(pos.lot_symbol)) continue;
try matched_symbols.put(pos.symbol, {});
const price = prices.get(pos.symbol) orelse pos.avg_cost;
const mv = pos.shares * price * pos.price_ratio;
portfolio_total += mv;
has_discrepancies = true;
try comparisons.append(allocator, .{
.symbol = pos.symbol,
.portfolio_shares = pos.shares,
.brokerage_shares = null,
.portfolio_value = mv,
.brokerage_value = null,
.shares_delta = null,
.value_delta = null,
.is_cash = false,
.only_in_brokerage = false,
.only_in_portfolio = true,
});
}
}
try results.append(allocator, .{
.account_name = portfolio_acct_name orelse "",
.brokerage_name = broker_name,
.account_number = acct_num,
.comparisons = try comparisons.toOwnedSlice(allocator),
.portfolio_total = portfolio_total,
.brokerage_total = brokerage_total,
.total_delta = brokerage_total - portfolio_total,
.has_discrepancies = has_discrepancies,
});
}
return results.toOwnedSlice(allocator);
}
// Display
fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void {
try cli.setBold(out, color);
try out.print("\nPortfolio Audit\n", .{});
try cli.reset(out, color);
try out.print("========================================\n\n", .{});
var total_portfolio: f64 = 0;
var total_brokerage: f64 = 0;
var discrepancy_count: usize = 0;
for (results) |acct| {
// Account header
try cli.setBold(out, color);
if (acct.account_name.len > 0) {
try out.print(" {s}", .{acct.account_name});
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number });
} else {
try out.print(" {s} #{s}", .{ acct.brokerage_name, acct.account_number });
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_WARNING);
try out.print(" (unmapped — add account_number to accounts.srf)\n", .{});
}
try cli.reset(out, color);
// Column headers
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:<24} {s:>12} {s:>12} {s:>14} {s:>14} {s}\n", .{
"Symbol", "PF Shares", "BR Shares", "PF Value", "BR Value", "Status",
});
try cli.reset(out, color);
for (acct.comparisons) |cmp| {
var pf_shares_buf: [16]u8 = undefined;
var br_shares_buf: [16]u8 = undefined;
var pf_val_buf: [24]u8 = undefined;
var br_val_buf: [24]u8 = undefined;
const pf_shares_str = if (cmp.is_cash)
"--"
else
std.fmt.bufPrint(&pf_shares_buf, "{d:.3}", .{cmp.portfolio_shares}) catch "?";
const br_shares_str = if (cmp.is_cash)
"--"
else if (cmp.brokerage_shares) |s|
(std.fmt.bufPrint(&br_shares_buf, "{d:.3}", .{s}) catch "?")
else
"--";
const pf_val_str = fmt.fmtMoneyAbs(&pf_val_buf, cmp.portfolio_value);
const br_val_str = if (cmp.brokerage_value) |v|
fmt.fmtMoneyAbs(&br_val_buf, @abs(v))
else
"--";
// Determine status
var status_buf: [64]u8 = undefined;
const status: []const u8 = blk: {
if (cmp.only_in_brokerage) break :blk "Brokerage only";
if (cmp.only_in_portfolio) break :blk "Portfolio only";
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else true;
const value_ok = if (cmp.value_delta) |d| @abs(d) < 1.0 else true;
if (shares_ok and value_ok) break :blk "ok";
if (!shares_ok) {
if (cmp.shares_delta) |d| {
const sign: []const u8 = if (d > 0) "+" else "";
break :blk std.fmt.bufPrint(&status_buf, "Shares {s}{d:.3}", .{ sign, d }) catch "Shares mismatch";
}
}
if (!value_ok) {
if (cmp.value_delta) |d| {
var delta_buf: [24]u8 = undefined;
const delta_str = fmt.fmtMoneyAbs(&delta_buf, @abs(d));
const sign: []const u8 = if (d >= 0) "+" else "-";
break :blk std.fmt.bufPrint(&status_buf, "Value {s}{s}", .{ sign, delta_str }) catch "Value mismatch";
}
}
break :blk "Mismatch";
};
// Color the status
const is_ok = std.mem.eql(u8, status, "ok");
if (!is_ok) discrepancy_count += 1;
try out.print(" {s:<24} {s:>12} {s:>12} {s:>14} {s:>14} ", .{
cmp.symbol, pf_shares_str, br_shares_str, pf_val_str, br_val_str,
});
if (is_ok) {
try cli.setFg(out, color, cli.CLR_POSITIVE);
} else {
try cli.setFg(out, color, cli.CLR_WARNING);
}
try out.print("{s}\n", .{status});
try cli.reset(out, color);
}
// Account totals
var pf_total_buf: [24]u8 = undefined;
var br_total_buf: [24]u8 = undefined;
var delta_buf: [24]u8 = undefined;
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:<24} {s:>12} {s:>12} {s:>14} {s:>14} ", .{
"", "", "", fmt.fmtMoneyAbs(&pf_total_buf, acct.portfolio_total), fmt.fmtMoneyAbs(&br_total_buf, acct.brokerage_total),
});
try cli.reset(out, color);
if (@abs(acct.total_delta) < 1.0) {
try cli.setFg(out, color, cli.CLR_POSITIVE);
try out.print("ok\n", .{});
} else {
try cli.setFg(out, color, cli.CLR_WARNING);
const sign: []const u8 = if (acct.total_delta >= 0) "+" else "-";
try out.print("Delta {s}{s}\n", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(acct.total_delta)) });
}
try cli.reset(out, color);
try out.print("\n", .{});
total_portfolio += acct.portfolio_total;
total_brokerage += acct.brokerage_total;
}
// Grand totals
var pf_grand_buf: [24]u8 = undefined;
var br_grand_buf: [24]u8 = undefined;
var grand_delta_buf: [24]u8 = undefined;
const grand_delta = total_brokerage - total_portfolio;
try cli.setBold(out, color);
try out.print(" Total: portfolio {s} brokerage {s}", .{
fmt.fmtMoneyAbs(&pf_grand_buf, total_portfolio),
fmt.fmtMoneyAbs(&br_grand_buf, total_brokerage),
});
try cli.reset(out, color);
if (@abs(grand_delta) < 1.0) {
try cli.setFg(out, color, cli.CLR_POSITIVE);
try out.print(" ok", .{});
} else {
try cli.setFg(out, color, cli.CLR_WARNING);
const sign: []const u8 = if (grand_delta >= 0) "+" else "-";
try out.print(" delta {s}{s}", .{ sign, fmt.fmtMoneyAbs(&grand_delta_buf, @abs(grand_delta)) });
}
try cli.reset(out, color);
try out.print("\n", .{});
if (discrepancy_count > 0) {
try cli.setFg(out, color, cli.CLR_WARNING);
try out.print(" {d} discrepancies found\n", .{discrepancy_count});
try cli.reset(out, color);
} else {
try cli.setFg(out, color, cli.CLR_POSITIVE);
try out.print(" All positions match\n", .{});
try cli.reset(out, color);
}
try out.print("\n", .{});
}
// CLI entry point
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const []const u8, color: bool, out: *std.Io.Writer) !void {
// Parse flags: --fidelity <csv> [--portfolio <file>]
var fidelity_csv: ?[]const u8 = null;
var portfolio_path: []const u8 = "portfolio.srf";
var i: usize = 0;
while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "--fidelity") and i + 1 < args.len) {
i += 1;
fidelity_csv = args[i];
} else if ((std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) and i + 1 < args.len) {
i += 1;
portfolio_path = args[i];
} else if (std.mem.eql(u8, args[i], "--no-color")) {
// handled globally
}
}
if (fidelity_csv == null) {
try cli.stderrPrint("Usage: zfin audit --fidelity <positions.csv> [-p <portfolio.srf>]\n");
return;
}
// Load portfolio
const pf_data = std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch {
try cli.stderrPrint("Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(pf_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
try cli.stderrPrint("Error: Cannot parse portfolio file\n");
return;
};
defer portfolio.deinit();
// Load accounts.srf
const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const acct_path = std.fmt.allocPrint(allocator, "{s}accounts.srf", .{portfolio_path[0..dir_end]}) catch return;
defer allocator.free(acct_path);
const acct_data = std.fs.cwd().readFileAlloc(allocator, acct_path, 1024 * 1024) catch {
try cli.stderrPrint("Error: Cannot read accounts.srf (needed for account number mapping)\n");
return;
};
defer allocator.free(acct_data);
var account_map = analysis.parseAccountsFile(allocator, acct_data) catch {
try cli.stderrPrint("Error: Cannot parse accounts.srf\n");
return;
};
defer account_map.deinit();
// Load brokerage data
if (fidelity_csv) |csv_path| {
const csv_data = std.fs.cwd().readFileAlloc(allocator, csv_path, 10 * 1024 * 1024) catch {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
try cli.stderrPrint(msg);
return;
};
defer allocator.free(csv_data);
const brokerage_positions = parseFidelityCsv(allocator, csv_data) catch {
try cli.stderrPrint("Error: Cannot parse Fidelity CSV (unexpected format?)\n");
return;
};
defer allocator.free(brokerage_positions);
// Get cached prices (no network fetching use whatever is cached)
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const positions = try portfolio.positions(allocator);
defer allocator.free(positions);
for (positions) |pos| {
if (svc.getCachedLastClose(pos.symbol)) |close| {
try prices.put(pos.symbol, close);
}
}
// Also resolve manual prices
for (portfolio.lots) |lot| {
if (lot.price) |p| {
if (!prices.contains(lot.priceSymbol())) {
try prices.put(lot.priceSymbol(), p * lot.price_ratio);
}
}
}
const results = try compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices);
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try displayResults(results, color, out);
}
}
// Tests
test "parseDollarAmount" {
try std.testing.expectApproxEqAbs(@as(f64, 1234.56), parseDollarAmount("$1,234.56").?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 7140.33), parseDollarAmount("$7140.33").?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 3732.40), parseDollarAmount("+$3,732.40").?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, -6300.00), parseDollarAmount("-$6,300.00").?, 0.01);
try std.testing.expect(parseDollarAmount("") == null);
try std.testing.expect(parseDollarAmount(" ") == null);
try std.testing.expectApproxEqAbs(@as(f64, 301), parseDollarAmount("301").?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 2387.616), parseDollarAmount("2387.616").?, 0.001);
}
test "parseFidelityCsv basic" {
const csv =
"\xEF\xBB\xBF" ++ // BOM
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\r\n" ++
"Z123,Individual,FZFXX**,HELD IN MONEY MARKET,,,,$5000.00,,,,,50%,,,Cash,\r\n" ++
"Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,50%,$10000.00,$100.00,Margin,\r\n" ++
"\r\n" ++
"\"Disclaimer text\"\r\n";
const allocator = std.testing.allocator;
const positions = try parseFidelityCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 2), positions.len);
// Cash position
try std.testing.expectEqualStrings("FZFXX", positions[0].symbol);
try std.testing.expect(positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 5000.00), positions[0].current_value.?, 0.01);
try std.testing.expect(positions[0].quantity == null);
// Stock position
try std.testing.expectEqualStrings("AAPL", positions[1].symbol);
try std.testing.expect(!positions[1].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[1].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 15000.00), positions[1].current_value.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[1].cost_basis.?, 0.01);
}
test "parseFidelityCsv stops at blank line" {
const csv =
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
"Z123,Individual,AAPL,APPLE INC,50,$150.00,+$2.00,$7500.00,+$100.00,+1.35%,+$2500.00,+50.00%,100%,$5000.00,$100.00,Margin,\n" ++
"\n" ++
"Z123,Individual,MSFT,SHOULD NOT APPEAR,10,$300.00,,,$3000.00,,,,,,,,\n";
const allocator = std.testing.allocator;
const positions = try parseFidelityCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqualStrings("AAPL", positions[0].symbol);
}
test "parseFidelityCsv option row" {
const csv =
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
"Z123,Individual, -AMZN260515C220,AMZN MAY 15 2026 $220 CALL,-3,$21.00,+$8.60,-$6300.00,-$2580.00,-69.36%,-$3674.02,-139.92%,-8.85%,$2625.98,$8.75,Margin,\n";
const allocator = std.testing.allocator;
const positions = try parseFidelityCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqualStrings("-AMZN260515C220", positions[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, -3), positions[0].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, -6300.00), positions[0].current_value.?, 0.01);
}
test "parseFidelityCsv empty file" {
const allocator = std.testing.allocator;
const result = parseFidelityCsv(allocator, "");
try std.testing.expectError(error.EmptyFile, result);
}
test "parseFidelityCsv wrong header" {
const allocator = std.testing.allocator;
const result = parseFidelityCsv(allocator, "Wrong,Header,Format\n");
try std.testing.expectError(error.UnexpectedHeader, result);
}
test "parseFidelityCsv cash account type is not cash position" {
// Fidelity's Type column says "Cash" for cash-account positions (vs "Margin").
// This does NOT mean the security is a cash holding only ** suffix means that.
const csv =
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
"X99,HSA,QTUM,DEFIANCE QUANTUM ETF,190,$116.14,+$0.31,$22066.60,+$58.90,+0.26%,+$1185.60,+5.67%,99.64%,$20881.00,$109.90,Cash,\n";
const allocator = std.testing.allocator;
const positions = try parseFidelityCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqualStrings("QTUM", positions[0].symbol);
try std.testing.expect(!positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
}

View file

@ -20,6 +20,7 @@ const usage =
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf) \\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI \\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ audit [opts] Reconcile portfolio against brokerage export
\\ cache stats Show cache statistics \\ cache stats Show cache statistics
\\ cache clear Clear all cached data \\ cache clear Clear all cached data
\\ \\
@ -42,6 +43,10 @@ const usage =
\\ -w, --watchlist <FILE> Watchlist file \\ -w, --watchlist <FILE> Watchlist file
\\ --refresh Force refresh (ignore cache, re-fetch all prices) \\ --refresh Force refresh (ignore cache, re-fetch all prices)
\\ \\
\\Audit command options:
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf)
\\
\\Analysis command: \\Analysis command:
\\ Reads metadata.srf (classification) and accounts.srf (tax types) \\ Reads metadata.srf (classification) and accounts.srf (tax types)
\\ from the same directory as the portfolio file. \\ from the same directory as the portfolio file.
@ -108,6 +113,7 @@ pub fn main() !u8 {
if (args.len >= 3 and if (args.len >= 3 and
!std.mem.eql(u8, command, "cache") and !std.mem.eql(u8, command, "cache") and
!std.mem.eql(u8, command, "enrich") and !std.mem.eql(u8, command, "enrich") and
!std.mem.eql(u8, command, "audit") and
!std.mem.eql(u8, command, "analysis") and !std.mem.eql(u8, command, "analysis") and
!std.mem.eql(u8, command, "portfolio")) !std.mem.eql(u8, command, "portfolio"))
{ {
@ -214,6 +220,8 @@ pub fn main() !u8 {
return 1; return 1;
} }
try commands.enrich.run(allocator, &svc, args[2], out); try commands.enrich.run(allocator, &svc, args[2], out);
} else if (std.mem.eql(u8, command, "audit")) {
try commands.audit.run(allocator, &svc, args[2..], color, out);
} else if (std.mem.eql(u8, command, "analysis")) { } else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf) // File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf"; var analysis_file: []const u8 = "portfolio.srf";
@ -248,6 +256,7 @@ const commands = struct {
const lookup = @import("commands/lookup.zig"); const lookup = @import("commands/lookup.zig");
const cache = @import("commands/cache.zig"); const cache = @import("commands/cache.zig");
const analysis = @import("commands/analysis.zig"); const analysis = @import("commands/analysis.zig");
const audit = @import("commands/audit.zig");
const enrich = @import("commands/enrich.zig"); const enrich = @import("commands/enrich.zig");
}; };

View file

@ -126,6 +126,9 @@ pub const Lot = struct {
/// Aggregated position for a single symbol across multiple lots. /// Aggregated position for a single symbol across multiple lots.
pub const Position = struct { pub const Position = struct {
symbol: []const u8, symbol: []const u8,
/// Original lot symbol before ticker aliasing (e.g. CUSIP "02315N600").
/// Same as `symbol` when no ticker alias is set.
lot_symbol: []const u8 = "",
/// Total open shares /// Total open shares
shares: f64, shares: f64,
/// Weighted average cost basis per share (open lots only) /// Weighted average cost basis per share (open lots only)
@ -252,6 +255,7 @@ pub const Portfolio = struct {
if (!entry.found_existing) { if (!entry.found_existing) {
entry.value_ptr.* = .{ entry.value_ptr.* = .{
.symbol = sym, .symbol = sym,
.lot_symbol = lot.symbol,
.shares = 0, .shares = 0,
.avg_cost = 0, .avg_cost = 0,
.total_cost = 0, .total_cost = 0,
@ -302,6 +306,78 @@ pub const Portfolio = struct {
return result.toOwnedSlice(allocator); return result.toOwnedSlice(allocator);
} }
/// Aggregate stock/ETF lots into positions for a single account.
/// Same logic as positions() but filtered to lots matching `account_name`.
/// Only includes positions with at least one open lot (closed-only symbols are excluded).
pub fn positionsForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8) ![]Position {
var map = std.StringHashMap(Position).init(allocator);
defer map.deinit();
for (self.lots) |lot| {
if (lot.security_type != .stock) continue;
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
const sym = lot.priceSymbol();
const entry = try map.getOrPut(sym);
if (!entry.found_existing) {
entry.value_ptr.* = .{
.symbol = sym,
.lot_symbol = lot.symbol,
.shares = 0,
.avg_cost = 0,
.total_cost = 0,
.open_lots = 0,
.closed_lots = 0,
.realized_gain_loss = 0,
.account = lot_acct,
.note = lot.note,
.price_ratio = lot.price_ratio,
};
} else {
if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) {
entry.value_ptr.price_ratio = lot.price_ratio;
}
}
if (lot.isOpen()) {
entry.value_ptr.shares += lot.shares;
entry.value_ptr.total_cost += lot.costBasis();
entry.value_ptr.open_lots += 1;
} else {
entry.value_ptr.closed_lots += 1;
entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0;
}
}
var iter = map.valueIterator();
while (iter.next()) |pos| {
if (pos.shares > 0) {
pos.avg_cost = pos.total_cost / pos.shares;
}
}
var result = std.ArrayList(Position).empty;
errdefer result.deinit(allocator);
var viter = map.valueIterator();
while (viter.next()) |pos| {
if (pos.open_lots == 0) continue;
try result.append(allocator, pos.*);
}
return result.toOwnedSlice(allocator);
}
/// Total cash for a single account.
pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type != .cash) continue;
const lot_acct = lot.account orelse continue;
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
}
return total;
}
/// Total cost basis of all open stock lots. /// Total cost basis of all open stock lots.
pub fn totalCostBasis(self: Portfolio) f64 { pub fn totalCostBasis(self: Portfolio) f64 {
var total: f64 = 0; var total: f64 = 0;
@ -564,3 +640,34 @@ test "positions propagates price_ratio from lot" {
} }
} }
} }
test "positionsForAccount excludes closed-only symbols" {
const allocator = std.testing.allocator;
var lots = [_]Lot{
// Open lot in account A
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
// Closed lot in account A (was sold)
.{ .symbol = "XLV", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 150.0, .account = "Acct A" },
// Open lot for same symbol in a different account
.{ .symbol = "XLV", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .account = "Acct B" },
};
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
// Account A: should only see AAPL (XLV is fully closed there)
const pos_a = try portfolio.positionsForAccount(allocator, "Acct A");
defer allocator.free(pos_a);
try std.testing.expectEqual(@as(usize, 1), pos_a.len);
try std.testing.expectEqualStrings("AAPL", pos_a[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), pos_a[0].shares, 0.01);
// Account B: should see XLV with 50 shares
const pos_b = try portfolio.positionsForAccount(allocator, "Acct B");
defer allocator.free(pos_b);
try std.testing.expectEqual(@as(usize, 1), pos_b.len);
try std.testing.expectEqualStrings("XLV", pos_b[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01);
}