zfin/src/commands/audit/fidelity.zig
2026-06-24 16:06:23 -07:00

91 lines
4.2 KiB
Zig

//! Fidelity reconciler for the `audit` command.
//!
//! Fidelity exports a single "all accounts" positions CSV. Parsing
//! lives in `brokerage/fidelity.zig`; this module wires that parser
//! into the shared per-account comparison engine in `common.zig`.
//! Because the Fidelity export is a plain per-account positions list,
//! the only Fidelity-specific knowledge here is "use the Fidelity CSV
//! parser" and "the institution key is `fidelity`" — everything else
//! (comparison, display) is shared.
const std = @import("std");
const zfin = @import("../../root.zig");
const analysis = @import("../../analytics/analysis.zig");
const Date = @import("../../Date.zig");
const common = @import("common.zig");
const fidelity_parser = @import("../../brokerage/fidelity.zig");
/// Parse a Fidelity positions CSV and reconcile it against the
/// portfolio. Returns owned `AccountComparison` results (free each
/// `.comparisons` slice, then the results slice). String fields in
/// the results borrow from `csv_data`, which must outlive them.
///
/// Propagates the parser's errors (`EmptyFile` / `UnexpectedHeader`)
/// and allocation failures so the caller can decide between a
/// user-facing message (explicit `--fidelity`) and silently skipping
/// the file (flagless auto-reconcile).
pub fn reconcile(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
csv_data: []const u8,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]common.AccountComparison {
const positions = try fidelity_parser.parseCsv(allocator, csv_data);
// The result strings borrow from `csv_data`, not from this slice,
// so freeing the slice array here is safe.
defer allocator.free(positions);
return common.compareAccounts(allocator, portfolio, positions, account_map, "fidelity", prices, as_of);
}
// ── Tests ────────────────────────────────────────────────────
const portfolio_mod = @import("../../models/portfolio.zig");
test "reconcile: parses Fidelity CSV and matches against portfolio" {
const allocator = std.testing.allocator;
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,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,100%,$10000.00,$100.00,Margin,\n";
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100.0, .account = "Sample Brokerage" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 150.0);
const results = try reconcile(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 5, 8));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
// 100 shares @ $150 on both sides → no discrepancy.
try std.testing.expect(!results[0].has_discrepancies);
}
test "reconcile: propagates parser errors" {
const allocator = std.testing.allocator;
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try std.testing.expectError(
error.EmptyFile,
reconcile(allocator, portfolio, "", acct_map, prices, Date.fromYmd(2026, 5, 8)),
);
}