add audit command (fidelity only for now)
This commit is contained in:
parent
d7a4aa6f63
commit
f3f9a20824
4 changed files with 893 additions and 1 deletions
|
|
@ -38,6 +38,8 @@ pub const TaxType = enum {
|
|||
pub const AccountTaxEntry = struct {
|
||||
account: []const u8,
|
||||
tax_type: TaxType,
|
||||
institution: ?[]const u8 = null,
|
||||
account_number: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Parsed account metadata.
|
||||
|
|
@ -48,6 +50,8 @@ pub const AccountMap = struct {
|
|||
pub fn deinit(self: *AccountMap) void {
|
||||
for (self.entries) |e| {
|
||||
self.allocator.free(e.account);
|
||||
if (e.institution) |s| self.allocator.free(s);
|
||||
if (e.account_number) |s| self.allocator.free(s);
|
||||
}
|
||||
self.allocator.free(self.entries);
|
||||
}
|
||||
|
|
@ -61,15 +65,42 @@ pub const AccountMap = struct {
|
|||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/// Find the portfolio account name for a given institution + account number.
|
||||
pub fn findByInstitutionAccount(self: AccountMap, institution: []const u8, account_number: []const u8) ?[]const u8 {
|
||||
for (self.entries) |e| {
|
||||
if (e.institution) |inst| {
|
||||
if (e.account_number) |num| {
|
||||
if (std.mem.eql(u8, inst, institution) and std.mem.eql(u8, num, account_number))
|
||||
return e.account;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Return all entries matching a given institution.
|
||||
pub fn entriesForInstitution(self: AccountMap, institution: []const u8) []const AccountTaxEntry {
|
||||
var count: usize = 0;
|
||||
for (self.entries) |e| {
|
||||
if (e.institution) |inst| {
|
||||
if (std.mem.eql(u8, inst, institution)) count += 1;
|
||||
}
|
||||
}
|
||||
if (count == 0) return &.{};
|
||||
return self.entries;
|
||||
}
|
||||
};
|
||||
|
||||
/// Parse an accounts.srf file into an AccountMap.
|
||||
/// Each record has: account::<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 {
|
||||
var entries = std.ArrayList(AccountTaxEntry).empty;
|
||||
errdefer {
|
||||
for (entries.items) |e| {
|
||||
allocator.free(e.account);
|
||||
if (e.institution) |s| allocator.free(s);
|
||||
if (e.account_number) |s| allocator.free(s);
|
||||
}
|
||||
entries.deinit(allocator);
|
||||
}
|
||||
|
|
@ -83,6 +114,8 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
|
|||
try entries.append(allocator, .{
|
||||
.account = try allocator.dupe(u8, entry.account),
|
||||
.tax_type = entry.tax_type,
|
||||
.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null,
|
||||
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
743
src/commands/audit.zig
Normal file
743
src/commands/audit.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ const usage =
|
|||
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
|
||||
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
|
||||
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
|
||||
\\ audit [opts] Reconcile portfolio against brokerage export
|
||||
\\ cache stats Show cache statistics
|
||||
\\ cache clear Clear all cached data
|
||||
\\
|
||||
|
|
@ -42,6 +43,10 @@ const usage =
|
|||
\\ -w, --watchlist <FILE> Watchlist file
|
||||
\\ --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:
|
||||
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
|
||||
\\ from the same directory as the portfolio file.
|
||||
|
|
@ -108,6 +113,7 @@ pub fn main() !u8 {
|
|||
if (args.len >= 3 and
|
||||
!std.mem.eql(u8, command, "cache") and
|
||||
!std.mem.eql(u8, command, "enrich") and
|
||||
!std.mem.eql(u8, command, "audit") and
|
||||
!std.mem.eql(u8, command, "analysis") and
|
||||
!std.mem.eql(u8, command, "portfolio"))
|
||||
{
|
||||
|
|
@ -214,6 +220,8 @@ pub fn main() !u8 {
|
|||
return 1;
|
||||
}
|
||||
try commands.enrich.run(allocator, &svc, args[2], out);
|
||||
} else if (std.mem.eql(u8, command, "audit")) {
|
||||
try commands.audit.run(allocator, &svc, args[2..], color, out);
|
||||
} else if (std.mem.eql(u8, command, "analysis")) {
|
||||
// File path is first non-flag arg (default: portfolio.srf)
|
||||
var analysis_file: []const u8 = "portfolio.srf";
|
||||
|
|
@ -248,6 +256,7 @@ const commands = struct {
|
|||
const lookup = @import("commands/lookup.zig");
|
||||
const cache = @import("commands/cache.zig");
|
||||
const analysis = @import("commands/analysis.zig");
|
||||
const audit = @import("commands/audit.zig");
|
||||
const enrich = @import("commands/enrich.zig");
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,9 @@ pub const Lot = struct {
|
|||
/// Aggregated position for a single symbol across multiple lots.
|
||||
pub const Position = struct {
|
||||
symbol: []const u8,
|
||||
/// Original lot symbol before ticker aliasing (e.g. CUSIP "02315N600").
|
||||
/// Same as `symbol` when no ticker alias is set.
|
||||
lot_symbol: []const u8 = "",
|
||||
/// Total open shares
|
||||
shares: f64,
|
||||
/// Weighted average cost basis per share (open lots only)
|
||||
|
|
@ -252,6 +255,7 @@ pub const Portfolio = struct {
|
|||
if (!entry.found_existing) {
|
||||
entry.value_ptr.* = .{
|
||||
.symbol = sym,
|
||||
.lot_symbol = lot.symbol,
|
||||
.shares = 0,
|
||||
.avg_cost = 0,
|
||||
.total_cost = 0,
|
||||
|
|
@ -302,6 +306,78 @@ pub const Portfolio = struct {
|
|||
return result.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Aggregate stock/ETF lots into positions for a single account.
|
||||
/// Same logic as positions() but filtered to lots matching `account_name`.
|
||||
/// Only includes positions with at least one open lot (closed-only symbols are excluded).
|
||||
pub fn positionsForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8) ![]Position {
|
||||
var map = std.StringHashMap(Position).init(allocator);
|
||||
defer map.deinit();
|
||||
|
||||
for (self.lots) |lot| {
|
||||
if (lot.security_type != .stock) continue;
|
||||
const lot_acct = lot.account orelse continue;
|
||||
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
|
||||
|
||||
const sym = lot.priceSymbol();
|
||||
const entry = try map.getOrPut(sym);
|
||||
if (!entry.found_existing) {
|
||||
entry.value_ptr.* = .{
|
||||
.symbol = sym,
|
||||
.lot_symbol = lot.symbol,
|
||||
.shares = 0,
|
||||
.avg_cost = 0,
|
||||
.total_cost = 0,
|
||||
.open_lots = 0,
|
||||
.closed_lots = 0,
|
||||
.realized_gain_loss = 0,
|
||||
.account = lot_acct,
|
||||
.note = lot.note,
|
||||
.price_ratio = lot.price_ratio,
|
||||
};
|
||||
} else {
|
||||
if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) {
|
||||
entry.value_ptr.price_ratio = lot.price_ratio;
|
||||
}
|
||||
}
|
||||
if (lot.isOpen()) {
|
||||
entry.value_ptr.shares += lot.shares;
|
||||
entry.value_ptr.total_cost += lot.costBasis();
|
||||
entry.value_ptr.open_lots += 1;
|
||||
} else {
|
||||
entry.value_ptr.closed_lots += 1;
|
||||
entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0;
|
||||
}
|
||||
}
|
||||
|
||||
var iter = map.valueIterator();
|
||||
while (iter.next()) |pos| {
|
||||
if (pos.shares > 0) {
|
||||
pos.avg_cost = pos.total_cost / pos.shares;
|
||||
}
|
||||
}
|
||||
|
||||
var result = std.ArrayList(Position).empty;
|
||||
errdefer result.deinit(allocator);
|
||||
|
||||
var viter = map.valueIterator();
|
||||
while (viter.next()) |pos| {
|
||||
if (pos.open_lots == 0) continue;
|
||||
try result.append(allocator, pos.*);
|
||||
}
|
||||
return result.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Total cash for a single account.
|
||||
pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
|
||||
var total: f64 = 0;
|
||||
for (self.lots) |lot| {
|
||||
if (lot.security_type != .cash) continue;
|
||||
const lot_acct = lot.account orelse continue;
|
||||
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Total cost basis of all open stock lots.
|
||||
pub fn totalCostBasis(self: Portfolio) f64 {
|
||||
var total: f64 = 0;
|
||||
|
|
@ -564,3 +640,34 @@ test "positions propagates price_ratio from lot" {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "positionsForAccount excludes closed-only symbols" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var lots = [_]Lot{
|
||||
// Open lot in account A
|
||||
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||||
// Closed lot in account A (was sold)
|
||||
.{ .symbol = "XLV", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 150.0, .account = "Acct A" },
|
||||
// Open lot for same symbol in a different account
|
||||
.{ .symbol = "XLV", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .account = "Acct B" },
|
||||
};
|
||||
|
||||
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
// Account A: should only see AAPL (XLV is fully closed there)
|
||||
const pos_a = try portfolio.positionsForAccount(allocator, "Acct A");
|
||||
defer allocator.free(pos_a);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), pos_a.len);
|
||||
try std.testing.expectEqualStrings("AAPL", pos_a[0].symbol);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 10.0), pos_a[0].shares, 0.01);
|
||||
|
||||
// Account B: should see XLV with 50 shares
|
||||
const pos_b = try portfolio.positionsForAccount(allocator, "Acct B");
|
||||
defer allocator.free(pos_b);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), pos_b.len);
|
||||
try std.testing.expectEqualStrings("XLV", pos_b[0].symbol);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue