3700 lines
151 KiB
Zig
3700 lines
151 KiB
Zig
const std = @import("std");
|
||
const zfin = @import("../root.zig");
|
||
const cli = @import("common.zig");
|
||
const framework = @import("framework.zig");
|
||
const fmt = cli.fmt;
|
||
const Money = @import("../Money.zig");
|
||
const analysis = @import("../analytics/analysis.zig");
|
||
const portfolio_mod = @import("../models/portfolio.zig");
|
||
const contributions = @import("contributions.zig");
|
||
|
||
// ── Brokerage position (normalized from any source) ─────────
|
||
|
||
/// A single position row from a brokerage export, normalized to a common format.
|
||
pub const BrokeragePosition = struct {
|
||
account_number: []const u8,
|
||
account_name: []const u8,
|
||
symbol: []const u8,
|
||
description: []const u8,
|
||
quantity: ?f64,
|
||
current_value: ?f64,
|
||
cost_basis: ?f64,
|
||
is_cash: bool,
|
||
};
|
||
|
||
/// Parsed brokerage data grouped by account.
|
||
pub const BrokerageAccount = struct {
|
||
account_number: []const u8,
|
||
account_name: []const u8,
|
||
portfolio_account: ?[]const u8,
|
||
positions: []const BrokeragePosition,
|
||
total_value: f64,
|
||
cash_value: f64,
|
||
};
|
||
|
||
// ── Fidelity CSV parser ─────────────────────────────────────
|
||
//
|
||
// Limitations of this CSV parser:
|
||
//
|
||
// 1. NOT a general-purpose CSV parser. It handles the specific format
|
||
// exported by Fidelity's "Download Positions" feature.
|
||
//
|
||
// 2. Does NOT handle quoted fields containing commas. Fidelity's export
|
||
// does not quote fields with commas in practice (description fields
|
||
// use spaces, not commas), but a truly compliant RFC 4180 parser would
|
||
// need to handle "field,with,commas" as a single value.
|
||
//
|
||
// 3. Does NOT handle escaped quotes ("" inside quoted fields).
|
||
//
|
||
// 4. Does NOT handle multi-line values (newlines inside quoted fields).
|
||
//
|
||
// 5. Assumes UTF-8 with optional BOM (which Fidelity includes).
|
||
//
|
||
// 6. Stops parsing at the first blank line, which separates position data
|
||
// from the Fidelity legal disclaimer footer.
|
||
//
|
||
// 7. Hardcodes the expected column layout. If Fidelity changes the CSV
|
||
// format (adds/removes/reorders columns), this parser will break.
|
||
// The header row is validated to catch this.
|
||
//
|
||
// 8. Dollar values like "$1,234.56" and "+$1,234.56" are stripped of
|
||
// $, commas, and leading +/- signs. Negative values wrapped in
|
||
// parentheses are NOT handled (Fidelity uses -$X.XX format).
|
||
//
|
||
// 9. Money market rows (symbol ending in **) are treated as cash.
|
||
//
|
||
// For a production-grade CSV parser, consider a library that handles
|
||
// RFC 4180 fully (quoted fields, escaping, multi-line values).
|
||
|
||
const fidelity_expected_columns = 16;
|
||
|
||
/// Column indices in the Fidelity CSV export.
|
||
/// Based on: Account Number, Account Name, Symbol, Description, Quantity,
|
||
/// Last Price, Last Price Change, Current Value, Today's Gain/Loss Dollar,
|
||
/// Today's Gain/Loss Percent, Total Gain/Loss Dollar, Total Gain/Loss Percent,
|
||
/// Percent Of Account, Cost Basis Total, Average Cost Basis, Type
|
||
const FidelityCol = struct {
|
||
const account_number = 0;
|
||
const account_name = 1;
|
||
const symbol = 2;
|
||
const description = 3;
|
||
const quantity = 4;
|
||
const last_price = 5;
|
||
const current_value = 7;
|
||
const cost_basis_total = 13;
|
||
const avg_cost_basis = 14;
|
||
const type_col = 15;
|
||
};
|
||
|
||
/// Parse a Fidelity CSV positions export into BrokeragePosition slices.
|
||
/// All string fields in the returned positions are slices into `data`,
|
||
/// so the caller must keep `data` alive for as long as the positions are used.
|
||
/// Only the returned slice itself is heap-allocated (caller must free it).
|
||
pub fn parseFidelityCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition {
|
||
var positions = std.ArrayList(BrokeragePosition).empty;
|
||
errdefer positions.deinit(allocator);
|
||
|
||
// Skip UTF-8 BOM if present
|
||
var content = data;
|
||
if (content.len >= 3 and content[0] == 0xEF and content[1] == 0xBB and content[2] == 0xBF) {
|
||
content = content[3..];
|
||
}
|
||
|
||
var lines = std.mem.splitScalar(u8, content, '\n');
|
||
|
||
// Validate header row
|
||
const header_line = lines.next() orelse return error.EmptyFile;
|
||
const header_trimmed = std.mem.trimEnd(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.trimEnd(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;
|
||
|
||
// 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;
|
||
|
||
// Classify as cash if any of:
|
||
// - Fidelity's ** suffix marks a money-market position
|
||
// - The symbol appears in zfin's canonical money-market list
|
||
// (e.g. FDRXX, SPAXX — Fidelity omits ** for some of these)
|
||
// - price and cost both equal exactly $1.00, the catch-all for
|
||
// fixed-NAV instruments that we don't have in the list yet.
|
||
const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or
|
||
portfolio_mod.isMoneyMarketSymbol(symbol_clean) or
|
||
isUnitPriceCash(cols[FidelityCol.last_price], cols[FidelityCol.avg_cost_basis]);
|
||
|
||
try positions.append(allocator, .{
|
||
.account_number = std.mem.trim(u8, cols[FidelityCol.account_number], &.{ ' ', '"' }),
|
||
.account_name = std.mem.trim(u8, cols[FidelityCol.account_name], &.{ ' ', '"' }),
|
||
.symbol = symbol_clean,
|
||
.description = std.mem.trim(u8, cols[FidelityCol.description], &.{ ' ', '"' }),
|
||
.quantity = if (is_cash) null else parseDollarAmount(cols[FidelityCol.quantity]),
|
||
.current_value = parseDollarAmount(cols[FidelityCol.current_value]),
|
||
.cost_basis = if (is_cash) null else parseDollarAmount(cols[FidelityCol.cost_basis_total]),
|
||
.is_cash = is_cash,
|
||
});
|
||
}
|
||
|
||
return positions.toOwnedSlice(allocator);
|
||
}
|
||
|
||
/// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00".
|
||
/// Strips $, commas, and +/- prefix. Returns null for empty or unparseable values.
|
||
fn parseDollarAmount(raw: []const u8) ?f64 {
|
||
const trimmed = std.mem.trim(u8, raw, &.{ ' ', '"' });
|
||
if (trimmed.len == 0) return null;
|
||
|
||
// Strip leading +/- and $, remove commas
|
||
var buf: [64]u8 = undefined;
|
||
var pos: usize = 0;
|
||
var negative = false;
|
||
for (trimmed) |c| {
|
||
if (c == '-') {
|
||
negative = true;
|
||
} else if (c == '$' or c == '+' or c == ',') {
|
||
continue;
|
||
} else {
|
||
if (pos >= buf.len) return null;
|
||
buf[pos] = c;
|
||
pos += 1;
|
||
}
|
||
}
|
||
if (pos == 0) return null;
|
||
|
||
const val = std.fmt.parseFloat(f64, buf[0..pos]) catch return null;
|
||
return if (negative) -val else val;
|
||
}
|
||
|
||
/// Returns true when both the last price and average cost basis parse to exactly $1.00,
|
||
/// indicating a money-market or cash-equivalent position (e.g. FDRXX).
|
||
fn isUnitPriceCash(price_raw: []const u8, cost_raw: []const u8) bool {
|
||
const price = parseDollarAmount(price_raw) orelse return false;
|
||
const cost = parseDollarAmount(cost_raw) orelse return false;
|
||
return price == 1.0 and cost == 1.0;
|
||
}
|
||
|
||
const Date = @import("../Date.zig");
|
||
|
||
/// Check if a Fidelity option symbol (e.g. "-AMZN260515C220") matches a
|
||
/// portfolio lot by comparing parsed components against the lot's structured
|
||
/// fields (underlying, maturity_date, option_type, strike).
|
||
///
|
||
/// Fidelity format: [-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}
|
||
/// The underlying length is variable, so we scan for the first position
|
||
/// where 6 consecutive digits encode a valid date.
|
||
fn fidelityOptionMatchesLot(symbol: []const u8, lot: portfolio_mod.Lot) bool {
|
||
if (lot.security_type != .option) return false;
|
||
|
||
// Strip leading dash (short indicator)
|
||
const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol;
|
||
|
||
// Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9
|
||
if (sym.len < 9) return false;
|
||
|
||
// Scan for the date boundary: first position where 6 consecutive digits
|
||
// form a valid YYMMDD (and the character before is a letter).
|
||
var i: usize = 1; // underlying is at least 1 char
|
||
while (i + 7 < sym.len) : (i += 1) {
|
||
// All 6 chars must be digits
|
||
if (!std.ascii.isDigit(sym[i]) or
|
||
!std.ascii.isDigit(sym[i + 1]) or
|
||
!std.ascii.isDigit(sym[i + 2]) or
|
||
!std.ascii.isDigit(sym[i + 3]) or
|
||
!std.ascii.isDigit(sym[i + 4]) or
|
||
!std.ascii.isDigit(sym[i + 5]))
|
||
continue;
|
||
|
||
// Character after the 6 digits must be C or P
|
||
const type_char = sym[i + 6];
|
||
if (type_char != 'C' and type_char != 'P') continue;
|
||
|
||
// Parse date components
|
||
const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue;
|
||
const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue;
|
||
const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue;
|
||
if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue;
|
||
const year = 2000 + yy;
|
||
|
||
// Parse components
|
||
const underlying = sym[0..i];
|
||
const option_type: portfolio_mod.OptionType = if (type_char == 'P') .put else .call;
|
||
const strike_str = sym[i + 7 ..];
|
||
const strike = std.fmt.parseFloat(f64, strike_str) catch continue;
|
||
const date = Date.fromYmd(year, mm, dd);
|
||
|
||
// Match against lot fields
|
||
const lot_underlying = lot.underlying orelse return false;
|
||
const lot_maturity = lot.maturity_date orelse return false;
|
||
|
||
if (!std.mem.eql(u8, underlying, lot_underlying)) return false;
|
||
if (!lot_maturity.eql(date)) return false;
|
||
if (option_type != lot.option_type) return false;
|
||
if (lot.strike) |ls| {
|
||
if (@abs(ls - strike) > 0.01) return false;
|
||
} else return false;
|
||
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ── 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.trimEnd(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.trimEnd(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;
|
||
|
||
// "Cash & Cash Investments" is Schwab's aggregate cash line.
|
||
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
|
||
// with their real ticker and price — treat those as cash too so
|
||
// the reconciliation matches what brokerage users think of as
|
||
// "cash" in the account.
|
||
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
|
||
portfolio_mod.isMoneyMarketSymbol(symbol);
|
||
|
||
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,
|
||
};
|
||
|
||
/// Resolved position value for audit display: effective per-share price
|
||
/// and total market value, with correct `price_ratio` handling based on
|
||
/// the price's provenance.
|
||
///
|
||
/// Two sources feed `prices`:
|
||
/// 1. Live candle close — NOT preadjusted for the lot's share class,
|
||
/// so `price_ratio` must be applied.
|
||
/// 2. `pos.avg_cost` fallback — already in the lot's share-class
|
||
/// terms (user paid institutional-class prices to open the lot),
|
||
/// so `price_ratio` must be skipped.
|
||
///
|
||
/// See the "Pricing model" block in `models/portfolio.zig` for the full
|
||
/// treatment. This helper is the audit-side mirror of the snapshot
|
||
/// side's `buildFallbackPrices` + `manual_set` pair.
|
||
const ResolvedValue = struct { price: f64, value: f64 };
|
||
|
||
fn resolvePositionValue(pos: zfin.Position, prices: std.StringHashMap(f64)) ResolvedValue {
|
||
if (prices.get(pos.symbol)) |live| {
|
||
return .{
|
||
.price = pos.effectivePrice(live, false),
|
||
.value = pos.marketValue(live, false),
|
||
};
|
||
}
|
||
// Fallback: avg_cost. Already preadjusted.
|
||
return .{
|
||
.price = pos.effectivePrice(pos.avg_cost, true),
|
||
.value = pos.marketValue(pos.avg_cost, true),
|
||
};
|
||
}
|
||
|
||
/// 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),
|
||
as_of: Date,
|
||
) ![]SchwabAccountComparison {
|
||
var results = std.ArrayList(SchwabAccountComparison).empty;
|
||
errdefer results.deinit(allocator);
|
||
|
||
for (schwab_accounts) |sa| {
|
||
const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number);
|
||
|
||
var pf_cash: f64 = 0;
|
||
var pf_total: f64 = 0;
|
||
|
||
if (portfolio_acct) |pa| {
|
||
pf_cash = portfolio.cashForAccount(pa);
|
||
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
|
||
}
|
||
|
||
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
|
||
const total_delta = if (sa.total_value) |st| st - pf_total else null;
|
||
|
||
const cash_ok = if (cash_delta) |d| @abs(d) < 1.0 else true;
|
||
const total_ok = if (total_delta) |d| @abs(d) < 1.0 else true;
|
||
|
||
try results.append(allocator, .{
|
||
.account_name = portfolio_acct orelse "",
|
||
.schwab_name = sa.account_name,
|
||
.account_number = sa.account_number,
|
||
.portfolio_cash = pf_cash,
|
||
.schwab_cash = sa.cash,
|
||
.cash_delta = cash_delta,
|
||
.portfolio_total = pf_total,
|
||
.schwab_total = sa.total_value,
|
||
.total_delta = total_delta,
|
||
.has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
|
||
});
|
||
}
|
||
|
||
return results.toOwnedSlice(allocator);
|
||
}
|
||
|
||
fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void {
|
||
try cli.printBold(out, color, "\nSchwab Account Audit", .{});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
|
||
try out.print("========================================\n\n", .{});
|
||
|
||
// Column headers
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>14} {s:>14} {s:>14} {s:>14}\n", .{
|
||
"Account", "PF Cash", "BR Cash", "PF Total", "BR Total",
|
||
});
|
||
|
||
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 br_cash_buf: [24]u8 = undefined;
|
||
var br_total_buf: [24]u8 = undefined;
|
||
|
||
const br_cash_str = if (r.schwab_cash) |c|
|
||
std.fmt.bufPrint(&br_cash_buf, "{f}", .{Money.from(c)}) catch "$?"
|
||
else
|
||
"--";
|
||
const br_total_str = if (r.schwab_total) |t|
|
||
std.fmt.bufPrint(&br_total_buf, "{f}", .{Money.from(t)}) catch "$?"
|
||
else
|
||
"--";
|
||
|
||
const cash_ok = if (r.cash_delta) |d| @abs(d) < 1.0 else true;
|
||
const total_ok = if (r.total_delta) |d| @abs(d) < 1.0 else true;
|
||
const is_unmapped = r.account_name.len == 0;
|
||
const is_real_mismatch = !cash_ok or is_unmapped;
|
||
|
||
if (is_real_mismatch) discrepancy_count += 1;
|
||
|
||
// Account label
|
||
try out.print(" ", .{});
|
||
if (is_unmapped) {
|
||
try cli.printFg(out, color, cli.CLR_WARNING, "{s:<24}", .{label});
|
||
} else {
|
||
try out.print("{s:<24}", .{label});
|
||
}
|
||
|
||
// PF Cash — colored if mismatched (brokerage is truth)
|
||
try out.print(" ", .{});
|
||
if (!cash_ok) {
|
||
const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_cash).padRight(14)});
|
||
} else {
|
||
try out.print("{f}", .{Money.from(r.portfolio_cash).padRight(14)});
|
||
}
|
||
|
||
// BR Cash
|
||
try out.print(" {s:>14}", .{br_cash_str});
|
||
|
||
// PF Total — colored if not just stale prices
|
||
try out.print(" ", .{});
|
||
if (!total_ok and !cash_ok) {
|
||
const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)});
|
||
} else {
|
||
try out.print("{f}", .{Money.from(r.portfolio_total).padRight(14)});
|
||
}
|
||
|
||
// BR Total
|
||
try out.print(" {s:>14}", .{br_total_str});
|
||
|
||
// Status
|
||
if (is_unmapped) {
|
||
try cli.printFg(out, color, cli.CLR_WARNING, " Unmapped", .{});
|
||
} else if (!cash_ok) {
|
||
const d = r.cash_delta.?;
|
||
const sign: []const u8 = if (d >= 0) "+" else "-";
|
||
try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) });
|
||
} else if (!total_ok) {
|
||
const d = r.total_delta.?;
|
||
const sign: []const u8 = if (d >= 0) "+" else "-";
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) });
|
||
}
|
||
try out.print("\n", .{});
|
||
|
||
grand_pf += r.portfolio_total;
|
||
if (r.schwab_total) |t| grand_br += t;
|
||
}
|
||
|
||
// Grand totals
|
||
try out.print("\n", .{});
|
||
const grand_delta = grand_br - grand_pf;
|
||
|
||
try cli.printBold(out, color, " Total: portfolio {f} schwab {f}", .{
|
||
Money.from(grand_pf),
|
||
Money.from(grand_br),
|
||
});
|
||
|
||
if (@abs(grand_delta) < 1.0) {
|
||
// no delta
|
||
} else {
|
||
const sign: []const u8 = if (grand_delta >= 0) "+" else "-";
|
||
const rgb = if (grand_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||
try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_delta)) });
|
||
}
|
||
try out.print("\n", .{});
|
||
|
||
if (discrepancy_count > 0) {
|
||
try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} — drill down with: zfin audit --schwab <account.csv>\n", .{
|
||
discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"),
|
||
});
|
||
}
|
||
try out.print("\n", .{});
|
||
}
|
||
|
||
// ── Audit logic ─────────────────────────────────────────────
|
||
|
||
/// Comparison result for a single symbol within an account.
|
||
pub const SymbolComparison = struct {
|
||
symbol: []const u8,
|
||
portfolio_shares: f64,
|
||
brokerage_shares: ?f64,
|
||
portfolio_price: ?f64,
|
||
brokerage_price: ?f64,
|
||
portfolio_value: f64,
|
||
brokerage_value: ?f64,
|
||
shares_delta: ?f64,
|
||
value_delta: ?f64,
|
||
is_cash: bool,
|
||
is_option: 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,
|
||
option_value_delta: f64,
|
||
has_discrepancies: bool,
|
||
};
|
||
|
||
/// Consolidate broker rows that share a symbol within the same
|
||
/// account into a single position. Some brokers split a single
|
||
/// stock holding into separate "Cash" and "Margin" rows for the
|
||
/// same ticker in the same account — Fidelity does this when a
|
||
/// freshly-credited lot (e.g. an RSU distribution) hasn't yet
|
||
/// cleared settlement (T+1 / T+2) and is therefore considered
|
||
/// un-marginable, while the older settled shares stay in the
|
||
/// margin sub-account. Without consolidation, the audit would
|
||
/// double-count when matching against the portfolio's
|
||
/// account-level aggregate.
|
||
///
|
||
/// Aggregation rules:
|
||
/// - `quantity` and `current_value` are summed across rows
|
||
/// (treating null as 0 for the sum, but preserving null when
|
||
/// no row supplied a value).
|
||
/// - `cost_basis` is summed the same way.
|
||
/// - `is_cash` is OR-ed across rows: any cash row in the group
|
||
/// marks the consolidated entry as cash. In practice a single
|
||
/// symbol is either always-cash (money market) or never (stock),
|
||
/// so this is just defensive.
|
||
/// - `account_number`, `account_name`, `description` are taken
|
||
/// from the first row in the group.
|
||
///
|
||
/// Caller owns the returned ArrayList.
|
||
fn consolidateBySymbol(
|
||
allocator: std.mem.Allocator,
|
||
rows: []const BrokeragePosition,
|
||
) !std.ArrayList(BrokeragePosition) {
|
||
var by_symbol = std.StringHashMap(usize).init(allocator);
|
||
defer by_symbol.deinit();
|
||
|
||
var out: std.ArrayList(BrokeragePosition) = .empty;
|
||
errdefer out.deinit(allocator);
|
||
|
||
for (rows) |bp| {
|
||
if (by_symbol.get(bp.symbol)) |idx| {
|
||
const existing = &out.items[idx];
|
||
// Sum quantity (null + value = value; null + null = null).
|
||
existing.quantity = sumOptional(existing.quantity, bp.quantity);
|
||
existing.current_value = sumOptional(existing.current_value, bp.current_value);
|
||
existing.cost_basis = sumOptional(existing.cost_basis, bp.cost_basis);
|
||
existing.is_cash = existing.is_cash or bp.is_cash;
|
||
} else {
|
||
try by_symbol.put(bp.symbol, out.items.len);
|
||
try out.append(allocator, bp);
|
||
}
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
fn sumOptional(a: ?f64, b: ?f64) ?f64 {
|
||
if (a == null and b == null) return null;
|
||
return (a orelse 0) + (b orelse 0);
|
||
}
|
||
|
||
/// 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),
|
||
as_of: Date,
|
||
) ![]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);
|
||
}
|
||
|
||
// Aggregate same-symbol rows within each account. Some brokers
|
||
// report a single security as multiple rows when a position
|
||
// straddles sub-account contexts. The motivating case is
|
||
// Fidelity's margin-eligible accounts: when a freshly-credited
|
||
// lot (e.g. an RSU distribution) hasn't yet cleared settlement
|
||
// (T+1 / T+2), Fidelity classifies the new shares as
|
||
// un-marginable "Cash" and the older settled shares as
|
||
// "Margin", reporting them as two CSV rows for the same
|
||
// ticker in the same account number. Once settlement clears,
|
||
// the rows usually consolidate back into one — but until
|
||
// then, the audit needs to consolidate them itself, otherwise
|
||
// it'd match each broker row independently against the
|
||
// (already-aggregated) portfolio total and report a phantom
|
||
// discrepancy on every duplicate. Aggregating here lets the
|
||
// rest of the comparator stay (account, symbol)-keyed
|
||
// regardless of how the broker chose to slice the rows.
|
||
var consolidated_accounts = std.StringHashMap(std.ArrayList(BrokeragePosition)).init(allocator);
|
||
defer {
|
||
var it = consolidated_accounts.valueIterator();
|
||
while (it.next()) |v| v.deinit(allocator);
|
||
consolidated_accounts.deinit();
|
||
}
|
||
{
|
||
var acct_it = brokerage_accounts.iterator();
|
||
while (acct_it.next()) |kv| {
|
||
const consolidated = try consolidateBySymbol(allocator, kv.value_ptr.items);
|
||
try consolidated_accounts.put(kv.key_ptr.*, consolidated);
|
||
}
|
||
}
|
||
|
||
// For each brokerage account, find the matching portfolio account and compare
|
||
var acct_iter = consolidated_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 option_value_delta: f64 = 0;
|
||
var has_discrepancies = false;
|
||
|
||
// Track which portfolio symbols we've matched
|
||
var matched_symbols = std.StringHashMap(void).init(allocator);
|
||
defer matched_symbols.deinit();
|
||
|
||
// Compare each brokerage position against portfolio
|
||
for (broker_positions) |bp| {
|
||
const bp_value = bp.current_value orelse 0;
|
||
brokerage_total += bp_value;
|
||
|
||
if (portfolio_acct_name == null) {
|
||
const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null;
|
||
try comparisons.append(allocator, .{
|
||
.symbol = bp.symbol,
|
||
.portfolio_shares = 0,
|
||
.brokerage_shares = bp.quantity,
|
||
.portfolio_price = null,
|
||
.brokerage_price = br_price,
|
||
.portfolio_value = 0,
|
||
.brokerage_value = bp.current_value,
|
||
.shares_delta = if (bp.quantity) |q| q else null,
|
||
.value_delta = bp.current_value,
|
||
.is_cash = bp.is_cash,
|
||
.is_option = false,
|
||
.only_in_brokerage = true,
|
||
.only_in_portfolio = false,
|
||
});
|
||
has_discrepancies = true;
|
||
continue;
|
||
}
|
||
|
||
// Sum portfolio lots for this symbol+account
|
||
var pf_shares: f64 = 0;
|
||
var pf_value: f64 = 0;
|
||
var pf_price: ?f64 = null;
|
||
var is_option = false;
|
||
|
||
if (bp.is_cash) {
|
||
pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
|
||
pf_value = pf_shares;
|
||
} else {
|
||
const acct_positions = portfolio.positionsForAccount(as_of, allocator, portfolio_acct_name.?) catch &.{};
|
||
defer allocator.free(acct_positions);
|
||
|
||
var found_stock = false;
|
||
for (acct_positions) |pos| {
|
||
if (!std.mem.eql(u8, pos.symbol, bp.symbol) and
|
||
!std.mem.eql(u8, pos.lot_symbol, bp.symbol))
|
||
continue;
|
||
pf_shares = pos.shares;
|
||
const v = resolvePositionValue(pos, prices);
|
||
pf_price = v.price;
|
||
pf_value = v.value;
|
||
try matched_symbols.put(pos.symbol, {});
|
||
try matched_symbols.put(pos.lot_symbol, {});
|
||
found_stock = true;
|
||
}
|
||
|
||
if (!found_stock) {
|
||
for (portfolio.lots) |lot| {
|
||
const lot_acct = lot.account orelse continue;
|
||
if (!std.mem.eql(u8, lot_acct, portfolio_acct_name.?)) continue;
|
||
if (!lot.isOpen(as_of)) continue;
|
||
// Match by exact symbol, or by parsed option components
|
||
// (Fidelity uses compact OCC format like "-AMZN260515C220"
|
||
// while portfolio uses "AMZN 05/15/2026 220.00 C")
|
||
if (!std.mem.eql(u8, lot.symbol, bp.symbol) and
|
||
!fidelityOptionMatchesLot(bp.symbol, lot)) continue;
|
||
switch (lot.security_type) {
|
||
.cd => {
|
||
pf_shares += lot.shares;
|
||
pf_value += lot.shares;
|
||
pf_price = 1.0;
|
||
},
|
||
.option => {
|
||
pf_shares += lot.shares;
|
||
pf_value += @abs(lot.shares) * lot.open_price * lot.multiplier;
|
||
pf_price = lot.open_price * lot.multiplier;
|
||
is_option = true;
|
||
},
|
||
else => {},
|
||
}
|
||
// Track the lot's own symbol so the portfolio-only pass skips it
|
||
try matched_symbols.put(lot.symbol, {});
|
||
}
|
||
if (pf_shares != 0) try matched_symbols.put(bp.symbol, {});
|
||
}
|
||
}
|
||
|
||
try matched_symbols.put(bp.symbol, {});
|
||
portfolio_total += pf_value;
|
||
|
||
const shares_delta = if (bp.quantity) |bq| bq - pf_shares else null;
|
||
const value_delta = if (bp.current_value) |bv| bv - pf_value else null;
|
||
|
||
const shares_match = if (shares_delta) |d| @abs(d) < 0.01 else true;
|
||
const value_match = if (value_delta) |d| @abs(d) < 1.0 else true;
|
||
|
||
// Option value deltas are expected (cost basis vs mark-to-market)
|
||
// — track them separately rather than flagging as discrepancies
|
||
if (is_option) {
|
||
if (value_delta) |d| option_value_delta += d;
|
||
if (!shares_match) has_discrepancies = true;
|
||
} else {
|
||
if (!shares_match or !value_match) has_discrepancies = true;
|
||
}
|
||
|
||
const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null;
|
||
|
||
try comparisons.append(allocator, .{
|
||
.symbol = bp.symbol,
|
||
.portfolio_shares = pf_shares,
|
||
.brokerage_shares = bp.quantity,
|
||
.portfolio_price = pf_price,
|
||
.brokerage_price = br_price,
|
||
.portfolio_value = pf_value,
|
||
.brokerage_value = bp.current_value,
|
||
.shares_delta = shares_delta,
|
||
.value_delta = value_delta,
|
||
.is_cash = bp.is_cash,
|
||
.is_option = is_option,
|
||
.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(as_of, 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 v = resolvePositionValue(pos, prices);
|
||
const mv = v.value;
|
||
portfolio_total += mv;
|
||
|
||
has_discrepancies = true;
|
||
try comparisons.append(allocator, .{
|
||
.symbol = pos.symbol,
|
||
.portfolio_shares = pos.shares,
|
||
.brokerage_shares = null,
|
||
.portfolio_price = v.price,
|
||
.brokerage_price = null,
|
||
.portfolio_value = mv,
|
||
.brokerage_value = null,
|
||
.shares_delta = null,
|
||
.value_delta = null,
|
||
.is_cash = false,
|
||
.is_option = false,
|
||
.only_in_brokerage = false,
|
||
.only_in_portfolio = true,
|
||
});
|
||
}
|
||
|
||
// Portfolio-only CDs and options
|
||
for (portfolio.lots) |lot| {
|
||
const lot_acct = lot.account orelse continue;
|
||
if (!std.mem.eql(u8, lot_acct, pa)) continue;
|
||
if (!lot.isOpen(as_of)) continue;
|
||
if (lot.security_type != .cd and lot.security_type != .option) continue;
|
||
if (matched_symbols.contains(lot.symbol)) continue;
|
||
|
||
try matched_symbols.put(lot.symbol, {});
|
||
|
||
var pf_shares: f64 = 0;
|
||
var pf_value: f64 = 0;
|
||
var pf_price: ?f64 = null;
|
||
var is_cd = false;
|
||
|
||
// Aggregate all lots with same symbol in this account
|
||
for (portfolio.lots) |lot2| {
|
||
const la2 = lot2.account orelse continue;
|
||
if (!std.mem.eql(u8, la2, pa)) continue;
|
||
if (!lot2.isOpen(as_of)) continue;
|
||
if (!std.mem.eql(u8, lot2.symbol, lot.symbol)) continue;
|
||
switch (lot2.security_type) {
|
||
.cd => {
|
||
pf_shares += lot2.shares;
|
||
pf_value += lot2.shares;
|
||
pf_price = 1.0;
|
||
is_cd = true;
|
||
},
|
||
.option => {
|
||
pf_shares += lot2.shares;
|
||
pf_value += @abs(lot2.shares) * lot2.open_price * lot2.multiplier;
|
||
pf_price = lot2.open_price * lot2.multiplier;
|
||
},
|
||
else => {},
|
||
}
|
||
}
|
||
|
||
if (pf_value != 0 or pf_shares != 0) {
|
||
portfolio_total += pf_value;
|
||
has_discrepancies = true;
|
||
try comparisons.append(allocator, .{
|
||
.symbol = lot.symbol,
|
||
.portfolio_shares = pf_shares,
|
||
.brokerage_shares = null,
|
||
.portfolio_price = pf_price,
|
||
.brokerage_price = null,
|
||
.portfolio_value = pf_value,
|
||
.brokerage_value = null,
|
||
.shares_delta = null,
|
||
.value_delta = null,
|
||
.is_cash = is_cd,
|
||
.is_option = !is_cd,
|
||
.only_in_brokerage = false,
|
||
.only_in_portfolio = true,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
try results.append(allocator, .{
|
||
.account_name = portfolio_acct_name orelse "",
|
||
.brokerage_name = broker_name,
|
||
.account_number = acct_num,
|
||
.comparisons = try comparisons.toOwnedSlice(allocator),
|
||
.portfolio_total = portfolio_total,
|
||
.brokerage_total = brokerage_total,
|
||
.total_delta = brokerage_total - portfolio_total,
|
||
.option_value_delta = option_value_delta,
|
||
.has_discrepancies = has_discrepancies,
|
||
});
|
||
}
|
||
|
||
return results.toOwnedSlice(allocator);
|
||
}
|
||
|
||
// ── Ratio suggestions ────────────────────────────────────────
|
||
|
||
/// After displaying audit results, check for price_ratio positions where
|
||
/// the brokerage NAV implies a different ratio than what's configured.
|
||
/// Outputs actionable suggestions for portfolio.srf updates.
|
||
///
|
||
/// Normally only lots with `price_ratio != 1.0` get suggestions —
|
||
/// the typical case is an institutional share class where the
|
||
/// configured ratio needs to drift toward current retail-vs-
|
||
/// institutional NAV. Lots with `price_ratio == 1.0` usually have
|
||
/// nothing to adjust.
|
||
///
|
||
/// Exception: accounts flagged `direct_indexing::true` in
|
||
/// `accounts.srf`. These are proxy baskets whose tracking-error
|
||
/// drift is expressed by periodically nudging the ratio even
|
||
/// though the starting ratio is 1.0. For those accounts we still
|
||
/// emit a suggestion when brokerage and portfolio values disagree
|
||
/// — the suggested ratio is just `brokerage_NAV / retail_price`
|
||
/// applied against the existing lot share count, same formula as
|
||
/// the institutional-class case.
|
||
fn displayRatioSuggestions(
|
||
results: []const AccountComparison,
|
||
portfolio: zfin.Portfolio,
|
||
prices: std.StringHashMap(f64),
|
||
account_map: ?analysis.AccountMap,
|
||
color: bool,
|
||
out: *std.Io.Writer,
|
||
) !void {
|
||
var has_header = false;
|
||
|
||
for (results) |acct| {
|
||
for (acct.comparisons) |cmp| {
|
||
// Skip unmatched, cash, and option rows
|
||
if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue;
|
||
if (cmp.is_cash or cmp.is_option) continue;
|
||
|
||
// Is this account flagged direct-indexing? Captured once
|
||
// per outer loop so the per-lot gate can skip the
|
||
// ratio == 1.0 check for flagged accounts.
|
||
const is_direct_indexing = if (account_map) |am|
|
||
am.isDirectIndexing(acct.account_name)
|
||
else
|
||
false;
|
||
|
||
// Find the portfolio lot(s) for this symbol with price_ratio != 1.0
|
||
// (or any ratio, for direct-indexing accounts).
|
||
for (portfolio.lots) |lot| {
|
||
if (lot.price_ratio == 1.0 and !is_direct_indexing) continue;
|
||
if (lot.security_type != .stock) continue;
|
||
const lot_acct = lot.account orelse continue;
|
||
if (!std.mem.eql(u8, lot_acct, acct.account_name)) continue;
|
||
|
||
// Match by lot_symbol (CUSIP) or ticker against brokerage symbol
|
||
const lot_sym = lot.symbol;
|
||
const price_sym = lot.priceSymbol();
|
||
if (!std.mem.eql(u8, lot_sym, cmp.symbol) and
|
||
!std.mem.eql(u8, price_sym, cmp.symbol)) continue;
|
||
|
||
// Get the retail price from cache
|
||
const retail_price = prices.get(price_sym) orelse continue;
|
||
// Brokerage price is the institutional NAV per share
|
||
const inst_nav = cmp.brokerage_price orelse continue;
|
||
if (retail_price == 0) continue;
|
||
|
||
const current_ratio = lot.price_ratio;
|
||
const suggested_ratio = inst_nav / retail_price;
|
||
const drift_pct = (suggested_ratio - current_ratio) / current_ratio * 100.0;
|
||
|
||
// Only suggest if drift is meaningful (> 0.01%)
|
||
if (current_ratio == suggested_ratio) break;
|
||
|
||
if (!has_header) {
|
||
try out.print("\n", .{});
|
||
try cli.printBold(out, color, " Ratio updates", .{});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf)\n", .{});
|
||
has_header = true;
|
||
}
|
||
|
||
var cur_buf: [24]u8 = undefined;
|
||
var sug_buf: [24]u8 = undefined;
|
||
var drift_buf: [16]u8 = undefined;
|
||
const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{current_ratio}) catch "?";
|
||
const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?";
|
||
const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.2}%", .{drift_pct}) catch "?";
|
||
|
||
try out.print(" {s:<16} ", .{lot_sym});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym});
|
||
try out.print(" ratio {s} -> ", .{cur_str});
|
||
try cli.printBold(out, color, "{s}", .{sug_str});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str});
|
||
|
||
break; // One suggestion per symbol
|
||
}
|
||
}
|
||
}
|
||
|
||
if (has_header) try out.print("\n", .{});
|
||
}
|
||
|
||
/// Direct-indexing ratio suggestions from Schwab-summary data.
|
||
///
|
||
/// The Schwab summary path only gives us per-account totals, not
|
||
/// per-symbol detail. For a direct-indexing account with exactly one
|
||
/// stock lot (the common case — the account is the proxy basket,
|
||
/// tracked as a single benchmark lot), we can still emit a ratio
|
||
/// suggestion from the account-level `total_delta`:
|
||
///
|
||
/// current_stock_value = portfolio_total - portfolio_cash
|
||
/// target_stock_value = current_stock_value + total_delta
|
||
/// suggested_ratio = target_stock_value / (shares × price)
|
||
///
|
||
/// Where `price` is `shares × current_cached_price`. The math
|
||
/// assumes the full account delta lands on the single tracked lot,
|
||
/// which is the semantics of a direct-indexing proxy.
|
||
///
|
||
/// Skips accounts with more than one stock lot (can't allocate the
|
||
/// delta) or zero stock lots (nothing to adjust).
|
||
fn displaySchwabSummaryRatioSuggestions(
|
||
results: []const SchwabAccountComparison,
|
||
portfolio: zfin.Portfolio,
|
||
prices: std.StringHashMap(f64),
|
||
account_map: ?analysis.AccountMap,
|
||
color: bool,
|
||
out: *std.Io.Writer,
|
||
) !void {
|
||
const am = account_map orelse return;
|
||
var has_header = false;
|
||
|
||
for (results) |r| {
|
||
if (r.account_name.len == 0) continue;
|
||
if (!am.isDirectIndexing(r.account_name)) continue;
|
||
const total_delta = r.total_delta orelse continue;
|
||
if (@abs(total_delta) < 0.01) continue;
|
||
|
||
// Find the single stock lot for this account.
|
||
var stock_lot: ?zfin.Lot = null;
|
||
var stock_lot_count: usize = 0;
|
||
for (portfolio.lots) |lot| {
|
||
if (lot.security_type != .stock) continue;
|
||
const lot_acct = lot.account orelse continue;
|
||
if (!std.mem.eql(u8, lot_acct, r.account_name)) continue;
|
||
stock_lot = lot;
|
||
stock_lot_count += 1;
|
||
}
|
||
if (stock_lot_count != 1) continue;
|
||
const lot = stock_lot.?;
|
||
|
||
const price_sym = lot.priceSymbol();
|
||
const retail_price = prices.get(price_sym) orelse continue;
|
||
if (retail_price == 0) continue;
|
||
if (lot.shares == 0) continue;
|
||
|
||
const current_stock_value = lot.shares * retail_price * lot.price_ratio;
|
||
if (current_stock_value == 0) continue;
|
||
const target_stock_value = current_stock_value + total_delta;
|
||
const suggested_ratio = target_stock_value / (lot.shares * retail_price);
|
||
const drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0;
|
||
|
||
if (!has_header) {
|
||
try out.print("\n", .{});
|
||
try cli.printBold(out, color, " Ratio updates", .{});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf; direct-indexing accounts)\n", .{});
|
||
has_header = true;
|
||
}
|
||
|
||
var cur_buf: [24]u8 = undefined;
|
||
var sug_buf: [24]u8 = undefined;
|
||
var drift_buf: [16]u8 = undefined;
|
||
const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{lot.price_ratio}) catch "?";
|
||
const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?";
|
||
const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.4}%", .{drift_pct}) catch "?";
|
||
|
||
try out.print(" {s:<16} ", .{lot.symbol});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym});
|
||
try out.print(" ratio {s} -> ", .{cur_str});
|
||
try cli.printBold(out, color, "{s}", .{sug_str});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str});
|
||
}
|
||
|
||
if (has_header) try out.print("\n", .{});
|
||
}
|
||
|
||
// ── Display ─────────────────────────────────────────────────
|
||
|
||
fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void {
|
||
try cli.printBold(out, color, "\nPortfolio Audit", .{});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
|
||
try out.print("========================================\n\n", .{});
|
||
|
||
var total_portfolio: f64 = 0;
|
||
var total_brokerage: f64 = 0;
|
||
var total_option_delta: f64 = 0;
|
||
var discrepancy_count: usize = 0;
|
||
|
||
for (results) |acct| {
|
||
// Account header
|
||
if (acct.account_name.len > 0) {
|
||
try cli.printBold(out, color, " {s}", .{acct.account_name});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number });
|
||
} else {
|
||
try cli.printBold(out, color, " {s} #{s}", .{ acct.brokerage_name, acct.account_number });
|
||
try cli.printFg(out, color, cli.CLR_WARNING, " (unmapped — add account_number to accounts.srf)\n", .{});
|
||
}
|
||
|
||
// Column headers
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {s:>10} {s:>10} {s}\n", .{
|
||
"Symbol", "PF Shares", "BR Shares", "PF Price", "BR Price", "",
|
||
});
|
||
|
||
for (acct.comparisons) |cmp| {
|
||
var pf_shares_buf: [16]u8 = undefined;
|
||
var br_shares_buf: [16]u8 = undefined;
|
||
var pf_price_buf: [16]u8 = undefined;
|
||
var br_price_buf: [16]u8 = undefined;
|
||
|
||
// Classify this row
|
||
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage;
|
||
const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= 1.0 else false);
|
||
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch;
|
||
|
||
if (is_real_mismatch) discrepancy_count += 1;
|
||
|
||
// Format share strings
|
||
const pf_shares_str: []const u8 = if (cmp.is_cash)
|
||
(std.fmt.bufPrint(&pf_shares_buf, "{f}", .{Money.from(cmp.portfolio_value)}) catch "$?")
|
||
else
|
||
std.fmt.bufPrint(&pf_shares_buf, "{d:.3}", .{cmp.portfolio_shares}) catch "?";
|
||
|
||
const br_shares_str: []const u8 = if (cmp.is_cash)
|
||
(if (cmp.brokerage_value) |v| (std.fmt.bufPrint(&br_shares_buf, "{f}", .{Money.from(v)}) catch "$?") else "--")
|
||
else if (cmp.brokerage_shares) |s|
|
||
(std.fmt.bufPrint(&br_shares_buf, "{d:.3}", .{s}) catch "?")
|
||
else
|
||
"--";
|
||
|
||
const pf_price_str: []const u8 = if (cmp.is_cash)
|
||
""
|
||
else if (cmp.portfolio_price) |p|
|
||
(std.fmt.bufPrint(&pf_price_buf, "{d:.2}", .{p}) catch "?")
|
||
else
|
||
"--";
|
||
|
||
const br_price_str: []const u8 = if (cmp.is_cash)
|
||
""
|
||
else if (cmp.brokerage_price) |p|
|
||
(std.fmt.bufPrint(&br_price_buf, "{d:.2}", .{p}) catch "?")
|
||
else
|
||
"--";
|
||
|
||
// Determine PF Shares color (relative to brokerage)
|
||
const shares_color: enum { normal, positive, negative, warning } = blk: {
|
||
if (cmp.only_in_portfolio) break :blk .warning;
|
||
if (cmp.is_cash and is_cash_mismatch) {
|
||
break :blk if (cmp.value_delta.? > 0) .negative else .positive;
|
||
}
|
||
if (shares_ok) break :blk .normal;
|
||
if (cmp.shares_delta) |d| {
|
||
break :blk if (d > 0) .negative else .positive;
|
||
}
|
||
break :blk .normal;
|
||
};
|
||
|
||
// Determine status text
|
||
var status_buf: [64]u8 = undefined;
|
||
const status: []const u8 = blk: {
|
||
if (cmp.only_in_brokerage) break :blk "Brokerage only";
|
||
if (cmp.only_in_portfolio) break :blk "Portfolio only";
|
||
|
||
if (is_cash_mismatch) {
|
||
if (cmp.value_delta) |d| {
|
||
const sign: []const u8 = if (d >= 0) "+" else "-";
|
||
break :blk std.fmt.bufPrint(&status_buf, "Cash {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "Cash mismatch";
|
||
}
|
||
}
|
||
|
||
if (!shares_ok) {
|
||
if (cmp.shares_delta) |d| {
|
||
const sign: []const u8 = if (d > 0) "+" else "";
|
||
break :blk std.fmt.bufPrint(&status_buf, "Shares {s}{d:.3}", .{ sign, d }) catch "Shares mismatch";
|
||
}
|
||
}
|
||
|
||
// Options: shares match is sufficient — value delta is expected
|
||
// (cost basis vs mark-to-market) and not actionable
|
||
if (cmp.is_option) break :blk "Option";
|
||
|
||
// Shares match — show value delta (stale price) if any, muted
|
||
if (cmp.value_delta) |d| {
|
||
if (@abs(d) >= 1.0) {
|
||
const sign: []const u8 = if (d >= 0) "+" else "-";
|
||
break :blk std.fmt.bufPrint(&status_buf, "Value {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "";
|
||
}
|
||
}
|
||
|
||
break :blk "";
|
||
};
|
||
|
||
// Status color: real mismatches in warning, stale values muted, ok is blank
|
||
const status_color: enum { warning, muted, none } = blk: {
|
||
if (is_real_mismatch) break :blk .warning;
|
||
if (status.len > 0) break :blk .muted;
|
||
break :blk .none;
|
||
};
|
||
|
||
// Print symbol
|
||
try out.print(" {s:<24} ", .{cmp.symbol});
|
||
|
||
// Print PF Shares with color
|
||
switch (shares_color) {
|
||
.positive => try cli.printFg(out, color, cli.CLR_POSITIVE, "{s:>12}", .{pf_shares_str}),
|
||
.negative => try cli.printFg(out, color, cli.CLR_NEGATIVE, "{s:>12}", .{pf_shares_str}),
|
||
.warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s:>12}", .{pf_shares_str}),
|
||
.normal => try out.print("{s:>12}", .{pf_shares_str}),
|
||
}
|
||
|
||
// Print BR Shares, prices
|
||
try out.print(" {s:>12} {s:>10} {s:>10} ", .{ br_shares_str, pf_price_str, br_price_str });
|
||
|
||
// Print status
|
||
switch (status_color) {
|
||
.warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s}\n", .{status}),
|
||
.muted => try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{status}),
|
||
.none => try out.print("{s}\n", .{status}),
|
||
}
|
||
}
|
||
|
||
// Account totals
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {f} {f} ", .{
|
||
"", "", "", Money.from(acct.portfolio_total).padRight(10), Money.from(acct.brokerage_total).padRight(10),
|
||
});
|
||
|
||
const adj_delta = acct.total_delta - acct.option_value_delta;
|
||
if (@abs(adj_delta) < 1.0) {
|
||
// no delta text needed
|
||
} else {
|
||
const sign: []const u8 = if (adj_delta >= 0) "+" else "-";
|
||
const rgb = if (adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||
try cli.printFg(out, color, rgb, "Delta {s}{f}", .{ sign, Money.from(@abs(adj_delta)) });
|
||
}
|
||
|
||
if (@abs(acct.option_value_delta) >= 1.0) {
|
||
const opt_sign: []const u8 = if (acct.option_value_delta >= 0) "+" else "-";
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) });
|
||
}
|
||
try out.print("\n\n", .{});
|
||
|
||
total_portfolio += acct.portfolio_total;
|
||
total_brokerage += acct.brokerage_total;
|
||
total_option_delta += acct.option_value_delta;
|
||
}
|
||
|
||
// Grand totals
|
||
const grand_delta = total_brokerage - total_portfolio;
|
||
const grand_adj_delta = grand_delta - total_option_delta;
|
||
|
||
try cli.printBold(out, color, " Total: portfolio {f} brokerage {f}", .{
|
||
Money.from(total_portfolio),
|
||
Money.from(total_brokerage),
|
||
});
|
||
|
||
if (@abs(grand_adj_delta) < 1.0) {
|
||
// no delta text needed
|
||
} else {
|
||
const sign: []const u8 = if (grand_adj_delta >= 0) "+" else "-";
|
||
const rgb = if (grand_adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||
try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_adj_delta)) });
|
||
}
|
||
|
||
if (@abs(total_option_delta) >= 1.0) {
|
||
const opt_sign: []const u8 = if (total_option_delta >= 0) "+" else "-";
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(total_option_delta)) });
|
||
}
|
||
try out.print("\n", .{});
|
||
|
||
if (discrepancy_count > 0) {
|
||
try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} to investigate\n", .{ discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches") });
|
||
}
|
||
try out.print("\n", .{});
|
||
}
|
||
|
||
// ── Hygiene check (flagless audit) ──────────────────────────
|
||
|
||
/// Constants for hygiene check behavior. Kept as named constants for
|
||
/// easy future tuning.
|
||
const audit_file_max_age_hours = 24;
|
||
const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only
|
||
const default_stale_days: u32 = 3;
|
||
const stale_warning_multiplier: u32 = 2; // yellow → red at 2× threshold
|
||
|
||
/// Dollar threshold above which a new lot (new_stock / new_drip_lot /
|
||
/// new_cash / new_cd / cash_contribution) gets flagged in the
|
||
/// "Large new lots — confirm source" hygiene section. Below this
|
||
/// threshold new lots pass silently — the audit's goal is to catch
|
||
/// unconfirmed six-figure movements, not flag every payroll
|
||
/// contribution.
|
||
///
|
||
/// $10k is a judgment call: high enough to ignore routine payroll
|
||
/// ESPP accruals and $1-$2k weekly deposits, low enough to surface
|
||
/// a typical IRA contribution or a genuine transfer. Tunable here,
|
||
/// per the plan's "revisit if the threshold proves wrong" note in
|
||
/// TODO.md.
|
||
const audit_large_lot_threshold: f64 = 10_000.0;
|
||
|
||
/// Type of a discovered brokerage file.
|
||
const BrokerFileKind = enum {
|
||
fidelity_csv,
|
||
schwab_csv,
|
||
schwab_summary,
|
||
};
|
||
|
||
/// A discovered brokerage file ready for reconciliation.
|
||
const DiscoveredFile = struct {
|
||
path: []const u8,
|
||
kind: BrokerFileKind,
|
||
dir_label: []const u8, // e.g. "audit/" or "$ZFIN_AUDIT_FILES"
|
||
};
|
||
|
||
/// Detect the brokerage type from file contents by inspecting the first few lines.
|
||
fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind {
|
||
// Strip optional UTF-8 BOM
|
||
const content = if (data.len >= 3 and data[0] == 0xEF and data[1] == 0xBB and data[2] == 0xBF)
|
||
data[3..]
|
||
else
|
||
data;
|
||
|
||
// Fidelity CSV: first line starts with "Account Number" or "Account Name"
|
||
if (std.mem.startsWith(u8, content, "Account Number") or
|
||
std.mem.startsWith(u8, content, "Account Name"))
|
||
return .fidelity_csv;
|
||
|
||
// Schwab per-account CSV: starts with a quoted title line like "Positions for ..."
|
||
if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv;
|
||
|
||
// Schwab summary: contains "Account number ending in" pattern
|
||
const peek = content[0..@min(content.len, 4096)];
|
||
if (std.mem.indexOf(u8, peek, "Account number ending in") != null) return .schwab_summary;
|
||
// Also match by account type labels + dollar amounts
|
||
if ((std.mem.indexOf(u8, peek, "Brokerage") != null or
|
||
std.mem.indexOf(u8, peek, "Roth IRA") != null or
|
||
std.mem.indexOf(u8, peek, "Traditional IRA") != null or
|
||
std.mem.indexOf(u8, peek, "Rollover IRA") != null) and
|
||
std.mem.indexOf(u8, peek, "$") != null)
|
||
{
|
||
return .schwab_summary;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// Discover brokerage files in a directory. Filters by recency (< 24h)
|
||
/// and applies size limits for non-CSV files.
|
||
fn discoverBrokerFiles(
|
||
io: std.Io,
|
||
allocator: std.mem.Allocator,
|
||
dir_path: []const u8,
|
||
dir_label: []const u8,
|
||
now_s: i64,
|
||
) ![]DiscoveredFile {
|
||
var results = std.ArrayList(DiscoveredFile).empty;
|
||
defer results.deinit(allocator);
|
||
|
||
var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return try results.toOwnedSlice(allocator);
|
||
defer dir.close(io);
|
||
|
||
const max_age_s: i128 = audit_file_max_age_hours * 3600;
|
||
|
||
var it = dir.iterate();
|
||
while (try it.next(io)) |entry| {
|
||
if (entry.kind != .file) continue;
|
||
|
||
// Check file modification time
|
||
const stat = dir.statFile(io, entry.name, .{}) catch continue;
|
||
const mtime_s: i128 = @divFloor(stat.mtime.nanoseconds, std.time.ns_per_s);
|
||
const age_s = now_s - mtime_s;
|
||
if (age_s > max_age_s) continue;
|
||
|
||
// Check if it's a CSV (no size limit) or non-CSV (size limit applies)
|
||
const is_csv = std.mem.endsWith(u8, entry.name, ".csv") or std.mem.endsWith(u8, entry.name, ".CSV");
|
||
if (!is_csv and stat.size > audit_file_max_size_non_csv) continue;
|
||
|
||
// Read and detect content type
|
||
const data = dir.readFileAlloc(io, entry.name, allocator, .limited(10 * 1024 * 1024)) catch continue;
|
||
defer allocator.free(data);
|
||
|
||
const kind = detectBrokerFileKind(data) orelse continue;
|
||
const full_path = std.fs.path.join(allocator, &.{ dir_path, entry.name }) catch continue;
|
||
try results.append(allocator, .{
|
||
.path = full_path,
|
||
.kind = kind,
|
||
.dir_label = dir_label,
|
||
});
|
||
}
|
||
|
||
return results.toOwnedSlice(allocator);
|
||
}
|
||
|
||
/// Compute which accounts have been modified between two parsed portfolios.
|
||
/// Returns a set of account names that have any lot-level differences.
|
||
/// Compares by serializing each lot to a canonical string per account,
|
||
/// sorting, and checking for equality. Simple and robust -- any field
|
||
/// change in a lot produces a different string.
|
||
fn findModifiedAccounts(
|
||
allocator: std.mem.Allocator,
|
||
old_portfolio: zfin.Portfolio,
|
||
new_portfolio: zfin.Portfolio,
|
||
) !std.StringHashMap(void) {
|
||
var modified = std.StringHashMap(void).init(allocator);
|
||
errdefer modified.deinit();
|
||
|
||
// Collect serialized lot strings grouped by account
|
||
var old_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
|
||
defer {
|
||
var it = old_accts.valueIterator();
|
||
while (it.next()) |v| {
|
||
for (v.items) |s| allocator.free(s);
|
||
v.deinit(allocator);
|
||
}
|
||
old_accts.deinit();
|
||
}
|
||
var new_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
|
||
defer {
|
||
var it = new_accts.valueIterator();
|
||
while (it.next()) |v| {
|
||
for (v.items) |s| allocator.free(s);
|
||
v.deinit(allocator);
|
||
}
|
||
new_accts.deinit();
|
||
}
|
||
|
||
for (old_portfolio.lots) |lot| {
|
||
const acct = lot.account orelse continue;
|
||
const entry = try old_accts.getOrPut(acct);
|
||
if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
|
||
try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
|
||
}
|
||
for (new_portfolio.lots) |lot| {
|
||
const acct = lot.account orelse continue;
|
||
const entry = try new_accts.getOrPut(acct);
|
||
if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
|
||
try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
|
||
}
|
||
|
||
// Compare per account: sort both lists, then check equality
|
||
var all = std.StringHashMap(void).init(allocator);
|
||
defer all.deinit();
|
||
{
|
||
var it = old_accts.keyIterator();
|
||
while (it.next()) |k| try all.put(k.*, {});
|
||
}
|
||
{
|
||
var it = new_accts.keyIterator();
|
||
while (it.next()) |k| try all.put(k.*, {});
|
||
}
|
||
|
||
var acct_it = all.keyIterator();
|
||
while (acct_it.next()) |acct_key| {
|
||
const acct = acct_key.*;
|
||
const old_ptr = old_accts.getPtr(acct);
|
||
const new_ptr = new_accts.getPtr(acct);
|
||
const old_len = if (old_ptr) |p| p.items.len else 0;
|
||
const new_len = if (new_ptr) |p| p.items.len else 0;
|
||
|
||
if (old_len != new_len) {
|
||
try modified.put(acct, {});
|
||
continue;
|
||
}
|
||
if (old_len == 0) continue;
|
||
|
||
const old_items = old_ptr.?.items;
|
||
const new_items = new_ptr.?.items;
|
||
std.mem.sort([]const u8, old_items, {}, strLessThan);
|
||
std.mem.sort([]const u8, new_items, {}, strLessThan);
|
||
|
||
var differs = false;
|
||
for (old_items, new_items) |a, b| {
|
||
if (!std.mem.eql(u8, a, b)) {
|
||
differs = true;
|
||
break;
|
||
}
|
||
}
|
||
if (differs) try modified.put(acct, {});
|
||
}
|
||
|
||
return modified;
|
||
}
|
||
|
||
fn strLessThan(_: void, a: []const u8, b: []const u8) bool {
|
||
return std.mem.order(u8, a, b) == .lt;
|
||
}
|
||
|
||
const srf = @import("srf");
|
||
|
||
/// Serialize a lot to a canonical SRF string for comparison.
|
||
/// Uses the SRF serializer with comptime reflection, so any new
|
||
/// field added to Lot is automatically included.
|
||
fn lotToString(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 {
|
||
const lots = [_]portfolio_mod.Lot{lot};
|
||
return std.fmt.allocPrint(allocator, "{f}", .{srf.fmtFrom(portfolio_mod.Lot, allocator, &lots, .{ .emit_directives = false })});
|
||
}
|
||
|
||
/// Staleness color based on age vs threshold.
|
||
/// Returns CLR_MUTED for within threshold, warning for 1-2x, negative for >2x.
|
||
fn stalenessColor(age_days: i32, threshold: u32) [3]u8 {
|
||
const t: i32 = @intCast(threshold);
|
||
if (age_days <= t) return cli.CLR_MUTED;
|
||
if (age_days <= t * @as(i32, stale_warning_multiplier)) return cli.CLR_WARNING;
|
||
return cli.CLR_NEGATIVE;
|
||
}
|
||
|
||
/// Render one unmatched large-lot warning. Formats the line the
|
||
/// user needs to paste into `transaction_log.srf` if the lot was
|
||
/// an internal movement rather than a real external contribution.
|
||
/// Leaves `from::<SOURCE>` as a placeholder — the audit doesn't
|
||
/// know which account the money came from.
|
||
///
|
||
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
|
||
/// (or cash_contribution) destinations use `dest_lot::cash`. The
|
||
/// template always uses `type::cash` since `type::in_kind` is
|
||
/// rejected downstream in v1.
|
||
fn printLargeLotWarning(
|
||
out: *std.Io.Writer,
|
||
lot: contributions.UnmatchedLargeLot,
|
||
color: bool,
|
||
) !void {
|
||
var val_buf: [32]u8 = undefined;
|
||
var date_buf: [10]u8 = undefined;
|
||
const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?";
|
||
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??";
|
||
const kind_label: []const u8 = switch (lot.security_type) {
|
||
.stock => "STOCK",
|
||
.cash => "CASH",
|
||
.cd => "CD",
|
||
.option => "OPTION",
|
||
else => "LOT",
|
||
};
|
||
|
||
const sym_for_display = if (lot.symbol.len > 0) lot.symbol else "cash";
|
||
try out.print(
|
||
" {s}: new {s} lot {s} ",
|
||
.{ lot.account, kind_label, sym_for_display },
|
||
);
|
||
try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str});
|
||
try out.print(" on {s}\n", .{date_str});
|
||
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{});
|
||
|
||
// Amount formatted as a whole-dollar number for the `num:`
|
||
// encoding; precise-to-the-cent values are rare in practice
|
||
// and callers can edit the template if needed.
|
||
const amount_int: i64 = @intFromFloat(@round(lot.value));
|
||
|
||
if (lot.security_type == .cash) {
|
||
try cli.printFg(
|
||
out,
|
||
color,
|
||
cli.CLR_MUTED,
|
||
" transfer::{s},type::cash,amount:num:{d},from::<SOURCE>,to::{s},dest_lot::cash\n",
|
||
.{ date_str, amount_int, lot.account },
|
||
);
|
||
} else {
|
||
try cli.printFg(
|
||
out,
|
||
color,
|
||
cli.CLR_MUTED,
|
||
" transfer::{s},type::cash,amount:num:{d},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
|
||
.{ date_str, amount_int, lot.account, lot.symbol, date_str },
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Run the flagless portfolio hygiene check.
|
||
fn runHygieneCheck(
|
||
io: std.Io,
|
||
allocator: std.mem.Allocator,
|
||
svc: *zfin.DataService,
|
||
portfolio_path: []const u8,
|
||
stale_days: u32,
|
||
verbose: bool,
|
||
as_of: Date,
|
||
now_s: i64,
|
||
color: bool,
|
||
out: *std.Io.Writer,
|
||
) !void {
|
||
// Load portfolio
|
||
const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||
try cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
|
||
return;
|
||
};
|
||
defer allocator.free(pf_data);
|
||
|
||
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
|
||
try cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
|
||
return;
|
||
};
|
||
defer portfolio.deinit();
|
||
|
||
// Load accounts.srf
|
||
var account_map = svc.loadAccountMap(portfolio_path) orelse {
|
||
try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n");
|
||
return;
|
||
};
|
||
defer account_map.deinit();
|
||
|
||
try cli.printBold(out, color, " Portfolio hygiene\n", .{});
|
||
|
||
// ── Section 1: Stale manual prices ──
|
||
|
||
var stale_count: usize = 0;
|
||
|
||
// Collect and display stale manual prices
|
||
{
|
||
var header_shown = false;
|
||
for (portfolio.lots) |lot| {
|
||
if (lot.price == null) continue;
|
||
const pd = lot.price_date orelse continue;
|
||
const age_days = as_of.days - pd.days;
|
||
const threshold: i32 = @intCast(stale_days);
|
||
if (age_days <= threshold) continue;
|
||
|
||
if (!header_shown) {
|
||
try out.print("\n", .{});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days — --stale-days to configure)\n", .{stale_days});
|
||
header_shown = true;
|
||
}
|
||
|
||
stale_count += 1;
|
||
var date_buf: [10]u8 = undefined;
|
||
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{pd}) catch "????-??-??";
|
||
const note_display = lot.note orelse "";
|
||
var price_buf: [24]u8 = undefined;
|
||
const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(lot.price.?)}) catch "$?";
|
||
|
||
try out.print(" {s:<16} {s:<16} {s:>10} {s} ", .{
|
||
lot.symbol,
|
||
note_display,
|
||
price_str,
|
||
date_str,
|
||
});
|
||
const clr = stalenessColor(age_days, stale_days);
|
||
try cli.printFg(out, color, clr, "({d} days)\n", .{@as(u32, @intCast(age_days))});
|
||
}
|
||
if (!header_shown) {
|
||
try out.print("\n", .{});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days)\n", .{stale_days});
|
||
try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{});
|
||
}
|
||
}
|
||
|
||
// ── Section 2: Account cadence check ──
|
||
{
|
||
// Try to get committed version via git
|
||
const git = @import("../git.zig");
|
||
const repo_info: ?git.RepoInfo = git.findRepo(io, allocator, portfolio_path) catch null;
|
||
defer if (repo_info) |ri| {
|
||
allocator.free(ri.root);
|
||
allocator.free(ri.rel_path);
|
||
};
|
||
|
||
// Parse committed portfolio for diff (working copy vs HEAD)
|
||
var committed_portfolio: ?zfin.Portfolio = null;
|
||
defer if (committed_portfolio) |*cp| cp.deinit();
|
||
|
||
var committed_data: ?[]const u8 = null;
|
||
defer if (committed_data) |d| allocator.free(d);
|
||
|
||
if (repo_info) |ri| {
|
||
committed_data = git.show(io, allocator, ri.root, "HEAD", ri.rel_path) catch null;
|
||
if (committed_data) |cd| {
|
||
committed_portfolio = zfin.cache.deserializePortfolio(allocator, cd) catch null;
|
||
}
|
||
}
|
||
|
||
// Find accounts modified in working copy (uncommitted changes)
|
||
var working_copy_modified = std.StringHashMap(void).init(allocator);
|
||
defer working_copy_modified.deinit();
|
||
|
||
if (committed_portfolio) |cp| {
|
||
working_copy_modified = findModifiedAccounts(allocator, cp, portfolio) catch std.StringHashMap(void).init(allocator);
|
||
}
|
||
|
||
// Collect all unique account names from working copy portfolio
|
||
// (these pointers are stable for the lifetime of the function)
|
||
var all_accounts = std.StringHashMap(void).init(allocator);
|
||
defer all_accounts.deinit();
|
||
for (portfolio.lots) |lot| {
|
||
if (lot.account) |acct| {
|
||
all_accounts.put(acct, {}) catch {};
|
||
}
|
||
}
|
||
|
||
// Find last update time for each account via git history.
|
||
// Walk commits newest-to-oldest, diffing adjacent pairs to find
|
||
// which accounts changed. Use working-copy account names as keys
|
||
// (stable lifetime) rather than historical portfolio strings.
|
||
// Only walk back far enough to hit red status (2× max cadence).
|
||
var last_update_ts = std.StringHashMap(i64).init(allocator);
|
||
defer last_update_ts.deinit();
|
||
|
||
if (repo_info) |ri| {
|
||
// Compute the furthest we need to look back: 2× the max cadence
|
||
var max_threshold: u32 = 14; // 2× weekly default
|
||
for (account_map.entries) |entry| {
|
||
if (entry.update_cadence.thresholdDays()) |td| {
|
||
const red = td * stale_warning_multiplier;
|
||
if (red > max_threshold) max_threshold = red;
|
||
}
|
||
}
|
||
var since_buf: [32]u8 = undefined;
|
||
const since = std.fmt.bufPrint(&since_buf, "{d} days ago", .{max_threshold}) catch "30 days ago";
|
||
|
||
const commits = git.listCommitsTouching(io, allocator, ri.root, ri.rel_path, since) catch &.{};
|
||
defer git.freeCommitTouches(allocator, commits);
|
||
|
||
var prev_data: ?[]const u8 = null;
|
||
defer if (prev_data) |pd| allocator.free(pd);
|
||
|
||
for (commits, 0..) |ct, ci| {
|
||
// Stop early if every account already has a timestamp
|
||
if (last_update_ts.count() >= all_accounts.count()) break;
|
||
|
||
const rev_data = git.show(io, allocator, ri.root, ct.commit, ri.rel_path) catch continue;
|
||
|
||
if (ci > 0) {
|
||
if (prev_data) |pd| {
|
||
// rev_data is older, pd is newer (commits are newest-first)
|
||
var old_pf = zfin.cache.deserializePortfolio(allocator, rev_data) catch {
|
||
allocator.free(rev_data);
|
||
continue;
|
||
};
|
||
defer old_pf.deinit();
|
||
var new_pf = zfin.cache.deserializePortfolio(allocator, pd) catch continue;
|
||
defer new_pf.deinit();
|
||
|
||
var mods = findModifiedAccounts(allocator, old_pf, new_pf) catch continue;
|
||
defer mods.deinit();
|
||
|
||
// The newer commit's timestamp is when these accounts were updated
|
||
const update_ts = commits[ci - 1].timestamp;
|
||
|
||
// Match against stable working-copy account names
|
||
var acct_iter = all_accounts.keyIterator();
|
||
while (acct_iter.next()) |stable_name| {
|
||
if (last_update_ts.contains(stable_name.*)) continue;
|
||
if (mods.contains(stable_name.*)) {
|
||
last_update_ts.put(stable_name.*, update_ts) catch {};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (prev_data) |pd| allocator.free(pd);
|
||
prev_data = rev_data;
|
||
}
|
||
}
|
||
|
||
// Display overdue accounts
|
||
var overdue_header_shown = false;
|
||
var updated_accounts = std.ArrayList([]const u8).empty;
|
||
defer updated_accounts.deinit(allocator);
|
||
|
||
// Check accounts updated in working copy
|
||
var wc_it = working_copy_modified.keyIterator();
|
||
while (wc_it.next()) |key| {
|
||
try updated_accounts.append(allocator, key.*);
|
||
}
|
||
|
||
// Check overdue accounts
|
||
var acct_it = all_accounts.keyIterator();
|
||
while (acct_it.next()) |acct_key| {
|
||
const acct_name = acct_key.*;
|
||
|
||
// Skip if already updated in working copy
|
||
if (working_copy_modified.contains(acct_name)) continue;
|
||
|
||
// Look up cadence from accounts.srf
|
||
var cadence = analysis.UpdateCadence.weekly; // default
|
||
for (account_map.entries) |entry| {
|
||
if (std.mem.eql(u8, entry.account, acct_name)) {
|
||
cadence = entry.update_cadence;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const threshold_days = cadence.thresholdDays() orelse continue; // skip 'none'
|
||
|
||
// Find last update time
|
||
var age_days: ?i32 = null;
|
||
if (last_update_ts.get(acct_name)) |ts| {
|
||
const age_s = now_s - ts;
|
||
age_days = @intCast(@divFloor(age_s, std.time.s_per_day));
|
||
}
|
||
|
||
// If we have no git history for this account, it's definitely overdue
|
||
const days = age_days orelse @as(i32, @intCast(threshold_days + 1));
|
||
if (days <= @as(i32, @intCast(threshold_days))) continue;
|
||
|
||
if (!overdue_header_shown) {
|
||
try out.print("\n", .{});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default — set update_cadence in accounts.srf)\n", .{});
|
||
overdue_header_shown = true;
|
||
}
|
||
|
||
try out.print(" {s:<32} {s:<10}", .{ acct_name, cadence.label() });
|
||
if (age_days) |ad| {
|
||
const clr = stalenessColor(ad, threshold_days);
|
||
try cli.printFg(out, color, clr, "last updated {d} days ago\n", .{@as(u32, @intCast(ad))});
|
||
} else {
|
||
try cli.printFg(out, color, cli.CLR_NEGATIVE, "no update history found\n", .{});
|
||
}
|
||
}
|
||
|
||
// Display accounts updated in working copy
|
||
if (updated_accounts.items.len > 0) {
|
||
try out.print("\n", .{});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " Accounts updated (working copy)\n", .{});
|
||
for (updated_accounts.items) |acct| {
|
||
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}\n", .{acct});
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Section 3: Discover brokerage files ──
|
||
|
||
// Resolve audit directories
|
||
const portfolio_dir = std.fs.path.dirnamePosix(portfolio_path) orelse ".";
|
||
|
||
var all_files = std.ArrayList(DiscoveredFile).empty;
|
||
defer {
|
||
for (all_files.items) |f| allocator.free(f.path);
|
||
all_files.deinit(allocator);
|
||
}
|
||
|
||
// Check $ZFIN_AUDIT_FILES first
|
||
const env_audit_dir = if (svc.config.environ_map) |em| em.get("ZFIN_AUDIT_FILES") else null;
|
||
if (env_audit_dir) |edir| {
|
||
const env_files = try discoverBrokerFiles(io, allocator, edir, "$ZFIN_AUDIT_FILES", now_s);
|
||
defer allocator.free(env_files);
|
||
for (env_files) |f| try all_files.append(allocator, f);
|
||
}
|
||
|
||
// Then check {portfolio_dir}/audit/
|
||
const default_audit_dir = std.fs.path.join(allocator, &.{ portfolio_dir, "audit" }) catch null;
|
||
defer if (default_audit_dir) |d| allocator.free(d);
|
||
|
||
if (default_audit_dir) |adir| {
|
||
const dir_files = try discoverBrokerFiles(io, allocator, adir, "audit/", now_s);
|
||
defer allocator.free(dir_files);
|
||
for (dir_files) |f| try all_files.append(allocator, f);
|
||
}
|
||
|
||
// Display discovered files
|
||
if (all_files.items.len > 0) {
|
||
try out.print("\n", .{});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " Brokerage files (last {d} hours)\n", .{audit_file_max_age_hours});
|
||
for (all_files.items) |f| {
|
||
const kind_label: []const u8 = switch (f.kind) {
|
||
.fidelity_csv => "fidelity",
|
||
.schwab_csv => "schwab csv",
|
||
.schwab_summary => "schwab summary",
|
||
};
|
||
try out.print(" {s:<52} {s}\n", .{ f.path, kind_label });
|
||
}
|
||
}
|
||
|
||
// ── Section 4: Auto-reconcile discovered files ──
|
||
|
||
if (all_files.items.len > 0) {
|
||
// Build prices map (shared by all reconciliations)
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
{
|
||
const pos_syms = try portfolio.stockSymbols(allocator);
|
||
defer allocator.free(pos_syms);
|
||
if (pos_syms.len > 0) {
|
||
var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, false, color);
|
||
defer load_result.deinit();
|
||
var pit = load_result.prices.iterator();
|
||
while (pit.next()) |entry| {
|
||
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
||
}
|
||
}
|
||
for (portfolio.lots) |lot| {
|
||
if (lot.price) |p| {
|
||
if (!prices.contains(lot.priceSymbol())) {
|
||
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
try out.print("\n", .{});
|
||
try cli.printBold(out, color, " Reconciliation\n", .{});
|
||
|
||
for (all_files.items) |f| {
|
||
const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue;
|
||
defer allocator.free(file_data);
|
||
|
||
switch (f.kind) {
|
||
.schwab_summary => {
|
||
const schwab_accounts = parseSchwabSummary(allocator, file_data) catch continue;
|
||
defer allocator.free(schwab_accounts);
|
||
|
||
const results = compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of) catch continue;
|
||
defer allocator.free(results);
|
||
|
||
if (verbose or hasSchwabDiscrepancies(results)) {
|
||
try out.print("\n", .{});
|
||
try displaySchwabResults(results, color, out);
|
||
try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||
} else {
|
||
var acct_count: usize = 0;
|
||
for (results) |r| {
|
||
if (r.account_name.len > 0) acct_count += 1;
|
||
}
|
||
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
|
||
// Always show ratio suggestions even in compact
|
||
// mode — direct-indexing drift may cause a
|
||
// non-zero delta that still deserves a nudge.
|
||
try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||
}
|
||
},
|
||
.fidelity_csv => {
|
||
const brokerage_positions = parseFidelityCsv(allocator, file_data) catch continue;
|
||
defer allocator.free(brokerage_positions);
|
||
|
||
const results = compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices, as_of) catch continue;
|
||
defer {
|
||
for (results) |r| allocator.free(r.comparisons);
|
||
allocator.free(results);
|
||
}
|
||
|
||
if (verbose or hasAccountDiscrepancies(results)) {
|
||
try out.print("\n", .{});
|
||
try displayResults(results, color, out);
|
||
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||
} else {
|
||
try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len});
|
||
// Always show ratio suggestions even in compact mode
|
||
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||
}
|
||
},
|
||
.schwab_csv => {
|
||
const parsed = parseSchwabCsv(allocator, file_data) catch continue;
|
||
defer allocator.free(parsed.positions);
|
||
|
||
const results = compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of) catch continue;
|
||
defer {
|
||
for (results) |r| allocator.free(r.comparisons);
|
||
allocator.free(results);
|
||
}
|
||
|
||
if (verbose or hasAccountDiscrepancies(results)) {
|
||
try out.print("\n", .{});
|
||
try displayResults(results, color, out);
|
||
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||
} else {
|
||
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
|
||
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||
}
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Section 5: Large new lots — confirm source ──
|
||
//
|
||
// Cross-check any new_* Change with value >= threshold against
|
||
// `transaction_log.srf` (via the shared contributions pipeline).
|
||
// Surfaces lots that look like significant external contributions
|
||
// OR unrecorded internal transfers — nudges the user to either
|
||
// confirm or add a transfer record.
|
||
//
|
||
// Silent when every large lot matched a transfer record, when
|
||
// there are no new lots at all, or when the pipeline can't run
|
||
// (not in a git repo). Threshold is a judgment call; see
|
||
// `audit_large_lot_threshold`.
|
||
if (contributions.findUnmatchedLargeLots(io, allocator, svc, portfolio_path, audit_large_lot_threshold, as_of, color)) |found| {
|
||
var found_mut = found;
|
||
defer found_mut.deinit();
|
||
|
||
if (found_mut.lots.len > 0) {
|
||
try out.print("\n", .{});
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots — confirm source\n", .{});
|
||
for (found_mut.lots) |lot| {
|
||
try printLargeLotWarning(out, lot, color);
|
||
}
|
||
}
|
||
}
|
||
|
||
try out.print("\n", .{});
|
||
}
|
||
|
||
/// Check if any Schwab summary results have discrepancies.
|
||
fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool {
|
||
for (results) |r| {
|
||
if (r.has_discrepancy) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// Check if any account comparison results have discrepancies.
|
||
fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
|
||
for (results) |r| {
|
||
if (r.has_discrepancies) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ── CLI entry point ─────────────────────────────────────────
|
||
|
||
pub const ParsedArgs = struct {
|
||
fidelity_csv: ?[]const u8 = null,
|
||
schwab_csv: ?[]const u8 = null,
|
||
schwab_summary: bool = false,
|
||
verbose: bool = false,
|
||
stale_days: u32 = default_stale_days,
|
||
};
|
||
|
||
pub const meta: framework.Meta = .{
|
||
.name = "audit",
|
||
.group = .hygiene,
|
||
.synopsis = "Reconcile portfolio against brokerage exports + portfolio hygiene check",
|
||
.help =
|
||
\\Usage: zfin audit [opts]
|
||
\\
|
||
\\Two modes in one command:
|
||
\\
|
||
\\ Flagless: run the portfolio hygiene check — surfaces stale
|
||
\\ manual prices, account-cadence violations, and brokerage-file
|
||
\\ candidates discovered automatically.
|
||
\\
|
||
\\ With brokerage flags: reconcile the portfolio against the
|
||
\\ given export and report discrepancies.
|
||
\\
|
||
\\Options:
|
||
\\ --verbose Show full reconciliation output even when clean
|
||
\\ --stale-days <N> Manual price staleness threshold (default 3)
|
||
\\ --fidelity <CSV> Fidelity positions CSV export
|
||
\\ ("All accounts" → Positions tab → Download)
|
||
\\ --schwab <CSV> Schwab per-account positions CSV export
|
||
\\ --schwab-summary Schwab account summary; copy from accounts
|
||
\\ summary page, paste to stdin, then ^D
|
||
\\
|
||
,
|
||
.uppercase_first_arg = false,
|
||
};
|
||
|
||
comptime {
|
||
framework.validateCommandModule(@This());
|
||
}
|
||
|
||
pub fn parseArgs(_: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||
var parsed: ParsedArgs = .{};
|
||
var i: usize = 0;
|
||
while (i < cmd_args.len) : (i += 1) {
|
||
if (std.mem.eql(u8, cmd_args[i], "--fidelity") and i + 1 < cmd_args.len) {
|
||
i += 1;
|
||
parsed.fidelity_csv = cmd_args[i];
|
||
} else if (std.mem.eql(u8, cmd_args[i], "--schwab") and i + 1 < cmd_args.len) {
|
||
i += 1;
|
||
parsed.schwab_csv = cmd_args[i];
|
||
} else if (std.mem.eql(u8, cmd_args[i], "--schwab-summary")) {
|
||
parsed.schwab_summary = true;
|
||
} else if (std.mem.eql(u8, cmd_args[i], "--verbose")) {
|
||
parsed.verbose = true;
|
||
} else if (std.mem.eql(u8, cmd_args[i], "--stale-days") and i + 1 < cmd_args.len) {
|
||
i += 1;
|
||
parsed.stale_days = std.fmt.parseInt(u32, cmd_args[i], 10) catch default_stale_days;
|
||
}
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||
const svc = ctx.svc orelse return error.MissingDataService;
|
||
const io = ctx.io;
|
||
const allocator = ctx.allocator;
|
||
const out = ctx.out;
|
||
const color = ctx.color;
|
||
const as_of = ctx.today;
|
||
const now_s = ctx.now_s;
|
||
|
||
const pf = ctx.resolvePortfolioPath();
|
||
defer pf.deinit(allocator);
|
||
const portfolio_path = pf.path;
|
||
|
||
const fidelity_csv = parsed.fidelity_csv;
|
||
const schwab_csv = parsed.schwab_csv;
|
||
const schwab_summary = parsed.schwab_summary;
|
||
const verbose = parsed.verbose;
|
||
const stale_days = parsed.stale_days;
|
||
|
||
// Flagless mode: run portfolio hygiene check
|
||
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
|
||
return runHygieneCheck(io, allocator, svc, portfolio_path, stale_days, verbose, as_of, now_s, color, out);
|
||
}
|
||
|
||
// Load portfolio
|
||
const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||
try cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
|
||
return;
|
||
};
|
||
defer allocator.free(pf_data);
|
||
|
||
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
|
||
try cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
|
||
return;
|
||
};
|
||
defer portfolio.deinit();
|
||
|
||
// Load accounts.srf
|
||
var account_map = svc.loadAccountMap(portfolio_path) orelse {
|
||
try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account number mapping)\n");
|
||
return;
|
||
};
|
||
defer account_map.deinit();
|
||
|
||
// Build prices map, shared by all audit modes.
|
||
//
|
||
// Route through `cli.loadPortfolioPrices` so the audit gets the same
|
||
// TTL-based cache refresh behavior `zfin portfolio` uses. Previously
|
||
// this read cached last-closes directly, which silently used stale
|
||
// data after long weekends / when the cache hadn't been refreshed.
|
||
// TTL-driven refetch keeps numbers current without forcing a full
|
||
// provider hit every run.
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
{
|
||
const pos_syms = try portfolio.stockSymbols(allocator);
|
||
defer allocator.free(pos_syms);
|
||
|
||
if (pos_syms.len > 0) {
|
||
var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, false, color);
|
||
defer load_result.deinit();
|
||
var it = load_result.prices.iterator();
|
||
while (it.next()) |entry| {
|
||
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
||
}
|
||
}
|
||
|
||
// Manual `price::` overrides from portfolio.srf still win for lots
|
||
// that carry them (e.g. 401k CIT shares with no API coverage).
|
||
for (portfolio.lots) |lot| {
|
||
if (lot.price) |p| {
|
||
if (!prices.contains(lot.priceSymbol())) {
|
||
// Pre-multiply — see "Pricing model" in models/portfolio.zig.
|
||
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Schwab summary from stdin
|
||
if (schwab_summary) {
|
||
try cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
|
||
var stdin_reader_buf: [4096]u8 = undefined;
|
||
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
|
||
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch {
|
||
try cli.stderrPrint(io, "Error: Cannot read stdin\n");
|
||
return;
|
||
};
|
||
defer allocator.free(stdin_data);
|
||
|
||
const schwab_accounts = parseSchwabSummary(allocator, stdin_data) catch {
|
||
try cli.stderrPrint(io, "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, as_of);
|
||
defer allocator.free(results);
|
||
|
||
try displaySchwabResults(results, color, out);
|
||
try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||
}
|
||
|
||
// Fidelity CSV
|
||
if (fidelity_csv) |csv_path| {
|
||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(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(io, msg);
|
||
return;
|
||
};
|
||
defer allocator.free(csv_data);
|
||
|
||
const brokerage_positions = parseFidelityCsv(allocator, csv_data) catch {
|
||
try cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
|
||
return;
|
||
};
|
||
defer allocator.free(brokerage_positions);
|
||
|
||
const results = try compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices, as_of);
|
||
defer {
|
||
for (results) |r| allocator.free(r.comparisons);
|
||
allocator.free(results);
|
||
}
|
||
|
||
try displayResults(results, color, out);
|
||
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||
}
|
||
|
||
// Schwab per-account CSV
|
||
if (schwab_csv) |csv_path| {
|
||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(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(io, msg);
|
||
return;
|
||
};
|
||
defer allocator.free(csv_data);
|
||
|
||
const csv = parseSchwabCsv(allocator, csv_data) catch {
|
||
try cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
|
||
return;
|
||
};
|
||
defer allocator.free(csv.positions);
|
||
|
||
const results = try compareAccounts(allocator, portfolio, csv.positions, account_map, "schwab", prices, as_of);
|
||
defer {
|
||
for (results) |r| allocator.free(r.comparisons);
|
||
allocator.free(results);
|
||
}
|
||
|
||
try displayResults(results, color, out);
|
||
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||
}
|
||
}
|
||
|
||
// ── Tests ────────────────────────────────────────────────────
|
||
|
||
test "parseArgs: defaults" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
const args = [_][]const u8{};
|
||
const parsed = try parseArgs(&ctx, &args);
|
||
try std.testing.expect(parsed.fidelity_csv == null);
|
||
try std.testing.expect(parsed.schwab_csv == null);
|
||
try std.testing.expect(!parsed.schwab_summary);
|
||
try std.testing.expect(!parsed.verbose);
|
||
try std.testing.expectEqual(default_stale_days, parsed.stale_days);
|
||
}
|
||
|
||
test "parseArgs: --fidelity captures CSV path" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
const args = [_][]const u8{ "--fidelity", "/tmp/fid.csv" };
|
||
const parsed = try parseArgs(&ctx, &args);
|
||
try std.testing.expectEqualStrings("/tmp/fid.csv", parsed.fidelity_csv.?);
|
||
}
|
||
|
||
test "parseArgs: --schwab captures CSV path" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
const args = [_][]const u8{ "--schwab", "/tmp/sch.csv" };
|
||
const parsed = try parseArgs(&ctx, &args);
|
||
try std.testing.expectEqualStrings("/tmp/sch.csv", parsed.schwab_csv.?);
|
||
}
|
||
|
||
test "parseArgs: --schwab-summary boolean" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
const args = [_][]const u8{"--schwab-summary"};
|
||
const parsed = try parseArgs(&ctx, &args);
|
||
try std.testing.expect(parsed.schwab_summary);
|
||
}
|
||
|
||
test "parseArgs: --verbose boolean" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
const args = [_][]const u8{"--verbose"};
|
||
const parsed = try parseArgs(&ctx, &args);
|
||
try std.testing.expect(parsed.verbose);
|
||
}
|
||
|
||
test "parseArgs: --stale-days parses integer" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
const args = [_][]const u8{ "--stale-days", "5" };
|
||
const parsed = try parseArgs(&ctx, &args);
|
||
try std.testing.expectEqual(@as(u32, 5), parsed.stale_days);
|
||
}
|
||
|
||
test "parseDollarAmount" {
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1234.56), parseDollarAmount("$1,234.56").?, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 7140.33), parseDollarAmount("$7140.33").?, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 3732.40), parseDollarAmount("+$3,732.40").?, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, -6300.00), parseDollarAmount("-$6,300.00").?, 0.01);
|
||
try std.testing.expect(parseDollarAmount("") == null);
|
||
try std.testing.expect(parseDollarAmount(" ") == null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 301), parseDollarAmount("301").?, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 2387.616), parseDollarAmount("2387.616").?, 0.001);
|
||
}
|
||
|
||
test "parseFidelityCsv basic" {
|
||
const csv =
|
||
"\xEF\xBB\xBF" ++ // BOM
|
||
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\r\n" ++
|
||
"Z123,Individual,FZFXX**,HELD IN MONEY MARKET,,,,$5000.00,,,,,50%,,,Cash,\r\n" ++
|
||
"Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,50%,$10000.00,$100.00,Margin,\r\n" ++
|
||
"\r\n" ++
|
||
"\"Disclaimer text\"\r\n";
|
||
|
||
const allocator = std.testing.allocator;
|
||
const positions = try parseFidelityCsv(allocator, csv);
|
||
defer allocator.free(positions);
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), positions.len);
|
||
|
||
// Cash position
|
||
try std.testing.expectEqualStrings("FZFXX", positions[0].symbol);
|
||
try std.testing.expect(positions[0].is_cash);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 5000.00), positions[0].current_value.?, 0.01);
|
||
try std.testing.expect(positions[0].quantity == null);
|
||
|
||
// Stock position
|
||
try std.testing.expectEqualStrings("AAPL", positions[1].symbol);
|
||
try std.testing.expect(!positions[1].is_cash);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[1].quantity.?, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 15000.00), positions[1].current_value.?, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[1].cost_basis.?, 0.01);
|
||
}
|
||
|
||
test "parseFidelityCsv treats $1.00 price+cost as cash" {
|
||
const csv =
|
||
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
|
||
"Z123,Individual,FDRXX,FID GOV CASH RESERVE,8500,$1.00,,$8500.00,,,,,10%,$8500.00,$1.00,Cash,\n";
|
||
|
||
const allocator = std.testing.allocator;
|
||
const positions = try parseFidelityCsv(allocator, csv);
|
||
defer allocator.free(positions);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||
try std.testing.expectEqualStrings("FDRXX", positions[0].symbol);
|
||
try std.testing.expect(positions[0].is_cash);
|
||
try std.testing.expect(positions[0].quantity == null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 8500.00), positions[0].current_value.?, 0.01);
|
||
}
|
||
|
||
test "parseFidelityCsv stops at blank line" {
|
||
const csv =
|
||
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
|
||
"Z123,Individual,AAPL,APPLE INC,50,$150.00,+$2.00,$7500.00,+$100.00,+1.35%,+$2500.00,+50.00%,100%,$5000.00,$100.00,Margin,\n" ++
|
||
"\n" ++
|
||
"Z123,Individual,MSFT,SHOULD NOT APPEAR,10,$300.00,,,$3000.00,,,,,,,,\n";
|
||
|
||
const allocator = std.testing.allocator;
|
||
const positions = try parseFidelityCsv(allocator, csv);
|
||
defer allocator.free(positions);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||
try std.testing.expectEqualStrings("AAPL", positions[0].symbol);
|
||
}
|
||
|
||
test "parseFidelityCsv option row" {
|
||
const csv =
|
||
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
|
||
"Z123,Individual, -AMZN260515C220,AMZN MAY 15 2026 $220 CALL,-3,$21.00,+$8.60,-$6300.00,-$2580.00,-69.36%,-$3674.02,-139.92%,-8.85%,$2625.98,$8.75,Margin,\n";
|
||
|
||
const allocator = std.testing.allocator;
|
||
const positions = try parseFidelityCsv(allocator, csv);
|
||
defer allocator.free(positions);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||
try std.testing.expectEqualStrings("-AMZN260515C220", positions[0].symbol);
|
||
try std.testing.expectApproxEqAbs(@as(f64, -3), positions[0].quantity.?, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, -6300.00), positions[0].current_value.?, 0.01);
|
||
}
|
||
|
||
test "parseFidelityCsv empty file" {
|
||
const allocator = std.testing.allocator;
|
||
const result = parseFidelityCsv(allocator, "");
|
||
try std.testing.expectError(error.EmptyFile, result);
|
||
}
|
||
|
||
test "parseFidelityCsv wrong header" {
|
||
const allocator = std.testing.allocator;
|
||
const result = parseFidelityCsv(allocator, "Wrong,Header,Format\n");
|
||
try std.testing.expectError(error.UnexpectedHeader, result);
|
||
}
|
||
|
||
test "parseFidelityCsv cash account type is not cash position" {
|
||
// Fidelity's Type column says "Cash" for cash-account positions (vs "Margin").
|
||
// This does NOT mean the security is a cash holding — only ** suffix means that.
|
||
const csv =
|
||
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
|
||
"X99,HSA,QTUM,DEFIANCE QUANTUM ETF,190,$116.14,+$0.31,$22066.60,+$58.90,+0.26%,+$1185.60,+5.67%,99.64%,$20881.00,$109.90,Cash,\n";
|
||
|
||
const allocator = std.testing.allocator;
|
||
const positions = try parseFidelityCsv(allocator, csv);
|
||
defer allocator.free(positions);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||
try std.testing.expectEqualStrings("QTUM", positions[0].symbol);
|
||
try std.testing.expect(!positions[0].is_cash);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
|
||
}
|
||
|
||
test "consolidateBySymbol: distinct symbols pass through unchanged" {
|
||
const allocator = std.testing.allocator;
|
||
const rows = [_]BrokeragePosition{
|
||
.{ .account_number = "A", .account_name = "Acct", .symbol = "AMZN", .description = "", .quantity = 39, .current_value = 10300, .cost_basis = 10000, .is_cash = false },
|
||
.{ .account_number = "A", .account_name = "Acct", .symbol = "QTUM", .description = "", .quantity = 100, .current_value = 14000, .cost_basis = 13000, .is_cash = false },
|
||
};
|
||
var out = try consolidateBySymbol(allocator, &rows);
|
||
defer out.deinit(allocator);
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), out.items.len);
|
||
try std.testing.expectEqualStrings("AMZN", out.items[0].symbol);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 39), out.items[0].quantity.?, 0.01);
|
||
try std.testing.expectEqualStrings("QTUM", out.items[1].symbol);
|
||
}
|
||
|
||
test "consolidateBySymbol: same-symbol rows aggregate quantity and value" {
|
||
// Reproduces the Fidelity Cash + Margin double-row scenario
|
||
// (newly-credited shares pre-settlement live in the cash
|
||
// sub-account; older settled shares live in the margin
|
||
// sub-account; both rows share the ticker). Both rows are
|
||
// AMZN in the same account; consolidation must sum to one
|
||
// entry of 40 shares total.
|
||
const allocator = std.testing.allocator;
|
||
const rows = [_]BrokeragePosition{
|
||
.{ .account_number = "A", .account_name = "Acct", .symbol = "AMZN", .description = "Cash row", .quantity = 39, .current_value = 10301.46, .cost_basis = 10244.55, .is_cash = false },
|
||
.{ .account_number = "A", .account_name = "Acct", .symbol = "AMZN", .description = "Margin row", .quantity = 1, .current_value = 264.14, .cost_basis = null, .is_cash = false },
|
||
};
|
||
var out = try consolidateBySymbol(allocator, &rows);
|
||
defer out.deinit(allocator);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), out.items.len);
|
||
try std.testing.expectEqualStrings("AMZN", out.items[0].symbol);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 40), out.items[0].quantity.?, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 10565.60), out.items[0].current_value.?, 0.01);
|
||
// cost_basis was null on the margin row but present on cash row;
|
||
// null + value = value preserves the cash row's basis.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 10244.55), out.items[0].cost_basis.?, 0.01);
|
||
try std.testing.expect(!out.items[0].is_cash);
|
||
}
|
||
|
||
test "consolidateBySymbol: null quantities collapse to null sum" {
|
||
// Two cash rows for the same money-market symbol — Fidelity reports
|
||
// these with quantity null and a dollar value. Sum the values, leave
|
||
// quantity null.
|
||
const allocator = std.testing.allocator;
|
||
const rows = [_]BrokeragePosition{
|
||
.{ .account_number = "A", .account_name = "Acct", .symbol = "FZFXX", .description = "", .quantity = null, .current_value = 100, .cost_basis = null, .is_cash = true },
|
||
.{ .account_number = "A", .account_name = "Acct", .symbol = "FZFXX", .description = "", .quantity = null, .current_value = 50, .cost_basis = null, .is_cash = true },
|
||
};
|
||
var out = try consolidateBySymbol(allocator, &rows);
|
||
defer out.deinit(allocator);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), out.items.len);
|
||
try std.testing.expectEqual(@as(?f64, null), out.items[0].quantity);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 150), out.items[0].current_value.?, 0.01);
|
||
try std.testing.expect(out.items[0].is_cash);
|
||
}
|
||
|
||
test "consolidateBySymbol: empty input returns empty" {
|
||
const allocator = std.testing.allocator;
|
||
const rows = [_]BrokeragePosition{};
|
||
var out = try consolidateBySymbol(allocator, &rows);
|
||
defer out.deinit(allocator);
|
||
try std.testing.expectEqual(@as(usize, 0), out.items.len);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// ── resolvePositionValue ──────────────────────────────────────
|
||
//
|
||
// Pins the audit-side price-provenance rule: live-from-cache prices
|
||
// get price_ratio applied; avg_cost-fallback prices do not. This
|
||
// closes the latent bug where institutional-share-class positions
|
||
// (price_ratio != 1.0) that missed the cache would have their value
|
||
// over-reported by the ratio factor.
|
||
|
||
test "resolvePositionValue: live cache hit applies price_ratio" {
|
||
const allocator = std.testing.allocator;
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
try prices.put("VTTHX", 27.78); // retail-class close
|
||
|
||
const pos: zfin.Position = .{
|
||
.symbol = "VTTHX",
|
||
.lot_symbol = "VTTHX",
|
||
.shares = 100,
|
||
.avg_cost = 106.18,
|
||
.total_cost = 10618,
|
||
.open_lots = 1,
|
||
.closed_lots = 0,
|
||
.realized_gain_loss = 0,
|
||
.account = "401k",
|
||
.price_ratio = 5.185,
|
||
};
|
||
|
||
const v = resolvePositionValue(pos, prices);
|
||
// price_ratio applied: 27.78 * 5.185 = 144.04
|
||
try std.testing.expectApproxEqAbs(@as(f64, 144.04), v.price, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 14403.93), v.value, 0.01);
|
||
}
|
||
|
||
test "resolvePositionValue: avg_cost fallback skips price_ratio" {
|
||
const allocator = std.testing.allocator;
|
||
// Empty prices map — simulate cache miss for VTTHX.
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
|
||
const pos: zfin.Position = .{
|
||
.symbol = "VTTHX",
|
||
.lot_symbol = "VTTHX",
|
||
.shares = 100,
|
||
.avg_cost = 106.18, // already institutional-class terms
|
||
.total_cost = 10618,
|
||
.open_lots = 1,
|
||
.closed_lots = 0,
|
||
.realized_gain_loss = 0,
|
||
.account = "401k",
|
||
.price_ratio = 5.185,
|
||
};
|
||
|
||
const v = resolvePositionValue(pos, prices);
|
||
// Pre-fix behavior would have multiplied: 106.18 * 5.185 = 550.55.
|
||
// Correct behavior: avg_cost is already in lot share-class terms.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 106.18), v.price, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 10618.0), v.value, 0.01);
|
||
}
|
||
|
||
test "resolvePositionValue: ratio-1.0 position unaffected by provenance" {
|
||
// Sanity: when price_ratio == 1.0, the bug never fired. Both paths
|
||
// should give the same answer.
|
||
const allocator = std.testing.allocator;
|
||
var prices_hit = std.StringHashMap(f64).init(allocator);
|
||
defer prices_hit.deinit();
|
||
try prices_hit.put("AAPL", 200.0);
|
||
|
||
var prices_miss = std.StringHashMap(f64).init(allocator);
|
||
defer prices_miss.deinit();
|
||
|
||
const pos: zfin.Position = .{
|
||
.symbol = "AAPL",
|
||
.lot_symbol = "AAPL",
|
||
.shares = 10,
|
||
.avg_cost = 150.0,
|
||
.total_cost = 1500,
|
||
.open_lots = 1,
|
||
.closed_lots = 0,
|
||
.realized_gain_loss = 0,
|
||
.account = "Roth",
|
||
};
|
||
|
||
const hit = resolvePositionValue(pos, prices_hit);
|
||
const miss = resolvePositionValue(pos, prices_miss);
|
||
|
||
try std.testing.expectApproxEqAbs(@as(f64, 200.0), hit.price, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 2000.0), hit.value, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 150.0), miss.price, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), miss.value, 0.01);
|
||
}
|
||
|
||
test "fidelityOptionMatchesLot basic call" {
|
||
const lot = portfolio_mod.Lot{
|
||
.symbol = "AMZN 05/15/2026 220.00 C",
|
||
.security_type = .option,
|
||
.underlying = "AMZN",
|
||
.strike = 220.0,
|
||
.option_type = .call,
|
||
.maturity_date = Date.fromYmd(2026, 5, 15),
|
||
.shares = -3,
|
||
.open_date = Date.fromYmd(2025, 1, 1),
|
||
.open_price = 8.75,
|
||
};
|
||
|
||
// Fidelity format with leading dash (short)
|
||
try std.testing.expect(fidelityOptionMatchesLot("-AMZN260515C220", lot));
|
||
// Without dash
|
||
try std.testing.expect(fidelityOptionMatchesLot("AMZN260515C220", lot));
|
||
// Wrong underlying
|
||
try std.testing.expect(!fidelityOptionMatchesLot("-MSFT260515C220", lot));
|
||
// Wrong date
|
||
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260615C220", lot));
|
||
// Wrong type
|
||
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515P220", lot));
|
||
// Wrong strike
|
||
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515C230", lot));
|
||
// Non-option lot
|
||
const stock_lot = portfolio_mod.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 };
|
||
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515C220", stock_lot));
|
||
}
|
||
|
||
test "fidelityOptionMatchesLot put option and decimal strike" {
|
||
const lot = portfolio_mod.Lot{
|
||
.symbol = "AAPL 06/20/2026 220.50 P",
|
||
.security_type = .option,
|
||
.underlying = "AAPL",
|
||
.strike = 220.50,
|
||
.option_type = .put,
|
||
.maturity_date = Date.fromYmd(2026, 6, 20),
|
||
.shares = -1,
|
||
.open_date = Date.fromYmd(2025, 1, 1),
|
||
.open_price = 5.0,
|
||
};
|
||
|
||
try std.testing.expect(fidelityOptionMatchesLot("-AAPL260620P220.50", lot));
|
||
try std.testing.expect(fidelityOptionMatchesLot("AAPL260620P220.50", lot));
|
||
// Call doesn't match put
|
||
try std.testing.expect(!fidelityOptionMatchesLot("-AAPL260620C220.50", lot));
|
||
}
|
||
|
||
test "fidelityOptionMatchesLot single-char underlying" {
|
||
const lot = portfolio_mod.Lot{
|
||
.symbol = "A 03/20/2026 150.00 C",
|
||
.security_type = .option,
|
||
.underlying = "A",
|
||
.strike = 150.0,
|
||
.option_type = .call,
|
||
.maturity_date = Date.fromYmd(2026, 3, 20),
|
||
.shares = -2,
|
||
.open_date = Date.fromYmd(2025, 1, 1),
|
||
.open_price = 3.0,
|
||
};
|
||
|
||
try std.testing.expect(fidelityOptionMatchesLot("-A260320C150", lot));
|
||
try std.testing.expect(!fidelityOptionMatchesLot("-A260320P150", lot));
|
||
}
|
||
|
||
test "option delta tracking in compareAccounts" {
|
||
const allocator = std.testing.allocator;
|
||
|
||
// Build a minimal portfolio with an option lot
|
||
var lots = [_]portfolio_mod.Lot{
|
||
.{
|
||
.symbol = "MSFT 05/15/2026 400.00 C",
|
||
.security_type = .option,
|
||
.underlying = "MSFT",
|
||
.strike = 400.0,
|
||
.option_type = .call,
|
||
.maturity_date = Date.fromYmd(2026, 5, 15),
|
||
.shares = -2,
|
||
.open_date = Date.fromYmd(2025, 1, 1),
|
||
.open_price = 6.68,
|
||
.multiplier = 100,
|
||
.account = "Emil IRA",
|
||
},
|
||
};
|
||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||
|
||
// Brokerage shows the option at different (mark-to-market) value
|
||
var brokerage = [_]BrokeragePosition{
|
||
.{
|
||
.account_number = "1234",
|
||
.account_name = "SCHWAB 1234",
|
||
.symbol = "MSFT 05/15/2026 400.00 C",
|
||
.description = "MSFT CALL",
|
||
.quantity = -2,
|
||
.current_value = -6511.20,
|
||
.cost_basis = -1336.0,
|
||
.is_cash = false,
|
||
},
|
||
};
|
||
|
||
// Account map: map schwab account 1234 -> portfolio "Emil IRA"
|
||
var entries = [_]analysis.AccountTaxEntry{
|
||
.{
|
||
.account = "Emil IRA",
|
||
.tax_type = .roth,
|
||
.institution = "schwab",
|
||
.account_number = "1234",
|
||
},
|
||
};
|
||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
|
||
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", 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);
|
||
const acct = results[0];
|
||
|
||
// Option should be matched, with option_value_delta tracking the difference
|
||
try std.testing.expect(@abs(acct.option_value_delta) > 1.0);
|
||
// Option value mismatch should NOT set has_discrepancies
|
||
try std.testing.expect(!acct.has_discrepancies);
|
||
|
||
// The comparison should be flagged as is_option
|
||
var found_option = false;
|
||
for (acct.comparisons) |cmp| {
|
||
if (cmp.is_option) {
|
||
found_option = true;
|
||
// Shares should match (-2 vs -2)
|
||
if (cmp.shares_delta) |d| {
|
||
try std.testing.expect(@abs(d) < 0.01);
|
||
}
|
||
}
|
||
}
|
||
try std.testing.expect(found_option);
|
||
}
|
||
|
||
test "detectBrokerFileKind: fidelity csv" {
|
||
const fidelity_header = "Account Number,Account Name,Symbol,Description";
|
||
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);
|
||
}
|
||
|
||
test "detectBrokerFileKind: fidelity csv with BOM" {
|
||
const fidelity_bom = "\xEF\xBB\xBFAccount Number,Account Name,Symbol";
|
||
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_bom).?);
|
||
}
|
||
|
||
test "detectBrokerFileKind: schwab csv" {
|
||
const schwab_header = "\"Positions for account Roth IRA ...716 as of\"";
|
||
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(schwab_header).?);
|
||
}
|
||
|
||
test "detectBrokerFileKind: schwab summary" {
|
||
const schwab_summary_data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00";
|
||
try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(schwab_summary_data).?);
|
||
}
|
||
|
||
test "detectBrokerFileKind: unknown file" {
|
||
const random_data = "This is just some random text that doesn't match any pattern";
|
||
try std.testing.expect(detectBrokerFileKind(random_data) == null);
|
||
}
|
||
|
||
test "stalenessColor: within threshold" {
|
||
try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(2, 3));
|
||
try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(3, 3));
|
||
}
|
||
|
||
test "stalenessColor: warning zone (1-2x threshold)" {
|
||
try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(4, 3));
|
||
try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(6, 3));
|
||
}
|
||
|
||
test "stalenessColor: critical zone (>2x threshold)" {
|
||
try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(7, 3));
|
||
try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(30, 3));
|
||
}
|
||
|
||
test "UpdateCadence thresholdDays" {
|
||
try std.testing.expectEqual(@as(?u32, 7), analysis.UpdateCadence.weekly.thresholdDays());
|
||
try std.testing.expectEqual(@as(?u32, 30), analysis.UpdateCadence.monthly.thresholdDays());
|
||
try std.testing.expectEqual(@as(?u32, 90), analysis.UpdateCadence.quarterly.thresholdDays());
|
||
try std.testing.expect(analysis.UpdateCadence.none.thresholdDays() == null);
|
||
}
|
||
|
||
test "findModifiedAccounts: detects share changes" {
|
||
const allocator = std.testing.allocator;
|
||
|
||
var old_lots = [_]portfolio_mod.Lot{
|
||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" },
|
||
};
|
||
var new_lots = [_]portfolio_mod.Lot{
|
||
.{ .symbol = "AAPL", .shares = 110, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, // shares changed
|
||
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, // unchanged
|
||
};
|
||
|
||
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
|
||
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
|
||
|
||
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
|
||
defer modified.deinit();
|
||
|
||
try std.testing.expect(modified.contains("Acct A"));
|
||
try std.testing.expect(!modified.contains("Acct B"));
|
||
}
|
||
|
||
test "findModifiedAccounts: detects new lots" {
|
||
const allocator = std.testing.allocator;
|
||
|
||
var old_lots = [_]portfolio_mod.Lot{
|
||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||
};
|
||
var new_lots = [_]portfolio_mod.Lot{
|
||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||
.{ .symbol = "VTI", .shares = 200, .open_date = Date.fromYmd(2025, 3, 1), .open_price = 200.0, .account = "Acct A" },
|
||
};
|
||
|
||
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
|
||
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
|
||
|
||
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
|
||
defer modified.deinit();
|
||
|
||
try std.testing.expect(modified.contains("Acct A"));
|
||
}
|
||
|
||
test "findModifiedAccounts: detects price changes" {
|
||
const allocator = std.testing.allocator;
|
||
|
||
var old_lots = [_]portfolio_mod.Lot{
|
||
.{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9) },
|
||
};
|
||
var new_lots = [_]portfolio_mod.Lot{
|
||
.{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18) },
|
||
};
|
||
|
||
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
|
||
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
|
||
|
||
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
|
||
defer modified.deinit();
|
||
|
||
try std.testing.expect(modified.contains("401k"));
|
||
}
|
||
|
||
test "findModifiedAccounts: detects removed lots" {
|
||
const allocator = std.testing.allocator;
|
||
|
||
var old_lots = [_]portfolio_mod.Lot{
|
||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||
.{ .symbol = "VTI", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Acct A" },
|
||
};
|
||
var new_lots = [_]portfolio_mod.Lot{
|
||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||
// VTI removed
|
||
};
|
||
|
||
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
|
||
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
|
||
|
||
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
|
||
defer modified.deinit();
|
||
|
||
try std.testing.expect(modified.contains("Acct A"));
|
||
}
|
||
|
||
test "findModifiedAccounts: no changes" {
|
||
const allocator = std.testing.allocator;
|
||
|
||
var lots = [_]portfolio_mod.Lot{
|
||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||
};
|
||
|
||
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||
|
||
var modified = try findModifiedAccounts(allocator, pf, pf);
|
||
defer modified.deinit();
|
||
|
||
try std.testing.expectEqual(@as(u32, 0), modified.count());
|
||
}
|
||
|
||
test "hasAccountDiscrepancies" {
|
||
const clean = [_]AccountComparison{.{
|
||
.account_name = "Acct",
|
||
.brokerage_name = "Schwab",
|
||
.account_number = "123",
|
||
.comparisons = &.{},
|
||
.portfolio_total = 1000,
|
||
.brokerage_total = 1000,
|
||
.total_delta = 0,
|
||
.option_value_delta = 0,
|
||
.has_discrepancies = false,
|
||
}};
|
||
try std.testing.expect(!hasAccountDiscrepancies(&clean));
|
||
|
||
const dirty = [_]AccountComparison{.{
|
||
.account_name = "Acct",
|
||
.brokerage_name = "Schwab",
|
||
.account_number = "123",
|
||
.comparisons = &.{},
|
||
.portfolio_total = 1000,
|
||
.brokerage_total = 1100,
|
||
.total_delta = 100,
|
||
.option_value_delta = 0,
|
||
.has_discrepancies = true,
|
||
}};
|
||
try std.testing.expect(hasAccountDiscrepancies(&dirty));
|
||
}
|
||
|
||
test "hasSchwabDiscrepancies" {
|
||
const clean = [_]SchwabAccountComparison{.{
|
||
.account_name = "IRA",
|
||
.schwab_name = "Roth IRA",
|
||
.account_number = "716",
|
||
.portfolio_cash = 100,
|
||
.schwab_cash = 100,
|
||
.cash_delta = 0,
|
||
.portfolio_total = 5000,
|
||
.schwab_total = 5000,
|
||
.total_delta = 0,
|
||
.has_discrepancy = false,
|
||
}};
|
||
try std.testing.expect(!hasSchwabDiscrepancies(&clean));
|
||
|
||
const dirty = [_]SchwabAccountComparison{.{
|
||
.account_name = "IRA",
|
||
.schwab_name = "Roth IRA",
|
||
.account_number = "716",
|
||
.portfolio_cash = 100,
|
||
.schwab_cash = 200,
|
||
.cash_delta = 100,
|
||
.portfolio_total = 5000,
|
||
.schwab_total = 5100,
|
||
.total_delta = 100,
|
||
.has_discrepancy = true,
|
||
}};
|
||
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
|
||
}
|
||
|
||
test "detectBrokerFileKind: schwab csv with Positions header" {
|
||
const data = "\"Positions for account Brokerage ...1234 as of 11:31 AM ET, 2026/04/25\"\n\nSymbol,Description,Quantity";
|
||
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
|
||
}
|
||
|
||
test "detectBrokerFileKind: schwab summary with Roth IRA" {
|
||
const data = "Roth IRA ...716\nSome text\n$50,000.00\n";
|
||
try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(data).?);
|
||
}
|
||
|
||
test "UpdateCadence label" {
|
||
try std.testing.expectEqualStrings("weekly", analysis.UpdateCadence.weekly.label());
|
||
try std.testing.expectEqualStrings("monthly", analysis.UpdateCadence.monthly.label());
|
||
try std.testing.expectEqualStrings("quarterly", analysis.UpdateCadence.quarterly.label());
|
||
try std.testing.expectEqualStrings("none", analysis.UpdateCadence.none.label());
|
||
}
|
||
|
||
test "discoverBrokerFiles: finds files in temp directory" {
|
||
const io = std.testing.io;
|
||
const allocator = std.testing.allocator;
|
||
|
||
// Create a temp directory with test files
|
||
var tmp = std.testing.tmpDir(.{});
|
||
defer tmp.cleanup();
|
||
|
||
// Write a fidelity CSV
|
||
tmp.dir.writeFile(io, .{
|
||
.sub_path = "fidelity.csv",
|
||
.data = "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Current Value\nZ123,Test,AAPL,Apple,100,200,20000\n",
|
||
}) catch unreachable;
|
||
|
||
// Write a schwab summary (non-CSV)
|
||
tmp.dir.writeFile(io, .{
|
||
.sub_path = "schwab.txt",
|
||
.data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00\n",
|
||
}) catch unreachable;
|
||
|
||
// Write a random non-matching file
|
||
tmp.dir.writeFile(io, .{
|
||
.sub_path = "notes.txt",
|
||
.data = "Just some random notes",
|
||
}) catch unreachable;
|
||
|
||
// Get the temp dir path
|
||
const tmp_path = tmp.dir.realPathFileAlloc(io, ".", allocator) catch unreachable;
|
||
defer allocator.free(tmp_path);
|
||
|
||
// wall-clock required: test writes real files and verifies they're
|
||
// treated as fresh. A fixed synthetic `now_s` would drift relative
|
||
// to the file mtime and produce flaky results.
|
||
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
||
const files = try discoverBrokerFiles(io, allocator, tmp_path, "test/", now_s);
|
||
defer {
|
||
for (files) |f| allocator.free(f.path);
|
||
allocator.free(files);
|
||
}
|
||
|
||
// Should find fidelity CSV and schwab summary, but not notes.txt
|
||
try std.testing.expectEqual(@as(usize, 2), files.len);
|
||
|
||
var found_fidelity = false;
|
||
var found_schwab = false;
|
||
for (files) |f| {
|
||
switch (f.kind) {
|
||
.fidelity_csv => found_fidelity = true,
|
||
.schwab_summary => found_schwab = true,
|
||
else => {},
|
||
}
|
||
}
|
||
try std.testing.expect(found_fidelity);
|
||
try std.testing.expect(found_schwab);
|
||
}
|
||
|
||
test "discoverBrokerFiles: empty directory returns empty" {
|
||
const io = std.testing.io;
|
||
const allocator = std.testing.allocator;
|
||
|
||
var tmp = std.testing.tmpDir(.{});
|
||
defer tmp.cleanup();
|
||
|
||
const tmp_path = tmp.dir.realPathFileAlloc(io, ".", allocator) catch unreachable;
|
||
defer allocator.free(tmp_path);
|
||
|
||
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
||
const files = try discoverBrokerFiles(io, allocator, tmp_path, "test/", now_s);
|
||
defer allocator.free(files);
|
||
|
||
try std.testing.expectEqual(@as(usize, 0), files.len);
|
||
}
|
||
|
||
test "discoverBrokerFiles: nonexistent directory returns empty" {
|
||
const io = std.testing.io;
|
||
const allocator = std.testing.allocator;
|
||
|
||
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
||
const files = try discoverBrokerFiles(io, allocator, "/nonexistent/path/audit", "test/", now_s);
|
||
defer allocator.free(files);
|
||
|
||
try std.testing.expectEqual(@as(usize, 0), files.len);
|
||
}
|
||
|
||
test "printLargeLotWarning: cash destination emits dest_lot::cash template" {
|
||
var buf: [1024]u8 = undefined;
|
||
var writer = std.Io.Writer.fixed(&buf);
|
||
|
||
const lot: contributions.UnmatchedLargeLot = .{
|
||
.account = "Acct A",
|
||
.symbol = "",
|
||
.security_type = .cash,
|
||
.value = 50_000.0,
|
||
.open_date = Date.fromYmd(2026, 5, 10),
|
||
};
|
||
|
||
try printLargeLotWarning(&writer, lot, false); // color=false → no ANSI escapes
|
||
const output = writer.buffered();
|
||
|
||
// Header line with account + value + date.
|
||
try std.testing.expect(std.mem.indexOf(u8, output, "Acct A: new CASH lot cash") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, output, "+$50,000.00") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null);
|
||
|
||
// Template line with the expected SRF shape.
|
||
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000,from::<SOURCE>,to::Acct A,dest_lot::cash") != null);
|
||
}
|
||
|
||
test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" {
|
||
var buf: [1024]u8 = undefined;
|
||
var writer = std.Io.Writer.fixed(&buf);
|
||
|
||
const lot: contributions.UnmatchedLargeLot = .{
|
||
.account = "Acct B",
|
||
.symbol = "SYM",
|
||
.security_type = .stock,
|
||
.value = 25_000.0,
|
||
.open_date = Date.fromYmd(2026, 5, 3),
|
||
};
|
||
|
||
try printLargeLotWarning(&writer, lot, false);
|
||
const output = writer.buffered();
|
||
|
||
try std.testing.expect(std.mem.indexOf(u8, output, "Acct B: new STOCK lot SYM") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, output, "+$25,000.00") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000,from::<SOURCE>,to::Acct B,dest_lot::SYM@2026-05-03") != null);
|
||
}
|
||
|
||
test "isUnitPriceCash: $1.00 + $1.00 returns true" {
|
||
try std.testing.expect(isUnitPriceCash("$1.00", "$1.00"));
|
||
try std.testing.expect(isUnitPriceCash("1.00", "1.00"));
|
||
try std.testing.expect(isUnitPriceCash("$1", "$1"));
|
||
}
|
||
|
||
test "isUnitPriceCash: non-$1 price returns false" {
|
||
try std.testing.expect(!isUnitPriceCash("$1.01", "$1.00"));
|
||
try std.testing.expect(!isUnitPriceCash("$1.00", "$1.01"));
|
||
try std.testing.expect(!isUnitPriceCash("$150.00", "$120.00"));
|
||
try std.testing.expect(!isUnitPriceCash("$0.99", "$1.00"));
|
||
}
|
||
|
||
test "isUnitPriceCash: unparseable inputs return false" {
|
||
try std.testing.expect(!isUnitPriceCash("", "$1.00"));
|
||
try std.testing.expect(!isUnitPriceCash("$1.00", ""));
|
||
try std.testing.expect(!isUnitPriceCash("N/A", "$1.00"));
|
||
}
|
||
|
||
test "strLessThan: orders strings lexicographically" {
|
||
try std.testing.expect(strLessThan({}, "AAPL", "MSFT"));
|
||
try std.testing.expect(!strLessThan({}, "MSFT", "AAPL"));
|
||
try std.testing.expect(!strLessThan({}, "AAPL", "AAPL"));
|
||
try std.testing.expect(strLessThan({}, "AAPL", "AAPLE"));
|
||
}
|
||
|
||
test "lotToString: stock lot includes symbol, shares, date" {
|
||
const allocator = std.testing.allocator;
|
||
const lot = portfolio_mod.Lot{
|
||
.symbol = "AAPL",
|
||
.shares = 100,
|
||
.open_date = Date.fromYmd(2024, 3, 15),
|
||
.open_price = 150.50,
|
||
};
|
||
const s = try lotToString(allocator, lot);
|
||
defer allocator.free(s);
|
||
try std.testing.expect(std.mem.indexOf(u8, s, "AAPL") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, s, "100") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, s, "2024-03-15") != null);
|
||
}
|
||
|
||
test "compareSchwabSummary: matching account → no discrepancy" {
|
||
const allocator = std.testing.allocator;
|
||
const today = Date.fromYmd(2026, 5, 8);
|
||
|
||
// Portfolio: $5000 cash + 10 AAPL @ open_price 150 = $1500 cost basis.
|
||
// With AAPL price=200, total = 5000 + 10*200 = 7000.
|
||
const lots = [_]portfolio_mod.Lot{
|
||
.{
|
||
.symbol = "CASH",
|
||
.shares = 5000,
|
||
.open_date = Date.fromYmd(2024, 1, 1),
|
||
.open_price = 1.0,
|
||
.security_type = .cash,
|
||
.account = "Emil Brokerage",
|
||
},
|
||
.{
|
||
.symbol = "AAPL",
|
||
.shares = 10,
|
||
.open_date = Date.fromYmd(2024, 1, 1),
|
||
.open_price = 150,
|
||
.account = "Emil Brokerage",
|
||
},
|
||
};
|
||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||
|
||
const schwab_accounts = [_]SchwabAccountSummary{
|
||
.{
|
||
.account_name = "Emil Brokerage",
|
||
.account_number = "1234",
|
||
.cash = 5000.0,
|
||
.total_value = 7000.0,
|
||
},
|
||
};
|
||
|
||
var entries = [_]analysis.AccountTaxEntry{
|
||
.{
|
||
.account = "Emil Brokerage",
|
||
.tax_type = .taxable,
|
||
.institution = "schwab",
|
||
.account_number = "1234",
|
||
},
|
||
};
|
||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
try prices.put("AAPL", 200.0);
|
||
|
||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||
defer allocator.free(results);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||
try std.testing.expectEqualStrings("Emil Brokerage", results[0].account_name);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 5000), results[0].portfolio_cash, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 7000), results[0].portfolio_total, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].cash_delta.?, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
||
try std.testing.expect(!results[0].has_discrepancy);
|
||
}
|
||
|
||
test "compareSchwabSummary: cash mismatch → has_discrepancy true" {
|
||
const allocator = std.testing.allocator;
|
||
const today = Date.fromYmd(2026, 5, 8);
|
||
|
||
// Portfolio cash = 5000, Schwab reports 5500 → $500 delta.
|
||
const lots = [_]portfolio_mod.Lot{
|
||
.{
|
||
.symbol = "CASH",
|
||
.shares = 5000,
|
||
.open_date = Date.fromYmd(2024, 1, 1),
|
||
.open_price = 1.0,
|
||
.security_type = .cash,
|
||
.account = "Brokerage",
|
||
},
|
||
};
|
||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||
|
||
const schwab_accounts = [_]SchwabAccountSummary{
|
||
.{
|
||
.account_name = "Brokerage",
|
||
.account_number = "1234",
|
||
.cash = 5500.0,
|
||
.total_value = 5500.0,
|
||
},
|
||
};
|
||
|
||
var entries = [_]analysis.AccountTaxEntry{
|
||
.{
|
||
.account = "Brokerage",
|
||
.tax_type = .taxable,
|
||
.institution = "schwab",
|
||
.account_number = "1234",
|
||
},
|
||
};
|
||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
|
||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||
defer allocator.free(results);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 500), results[0].cash_delta.?, 0.01);
|
||
try std.testing.expect(results[0].has_discrepancy);
|
||
}
|
||
|
||
test "compareSchwabSummary: account_number with no match → empty account_name" {
|
||
const allocator = std.testing.allocator;
|
||
const today = Date.fromYmd(2026, 5, 8);
|
||
|
||
const lots = [_]portfolio_mod.Lot{};
|
||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||
|
||
const schwab_accounts = [_]SchwabAccountSummary{
|
||
.{
|
||
.account_name = "Unknown Acct",
|
||
.account_number = "9999",
|
||
.cash = 1000.0,
|
||
.total_value = 1000.0,
|
||
},
|
||
};
|
||
|
||
var entries = [_]analysis.AccountTaxEntry{};
|
||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
|
||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||
defer allocator.free(results);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||
try std.testing.expectEqualStrings("", results[0].account_name);
|
||
try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
|
||
// No portfolio match → cash and total are zero, schwab values become deltas
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
|
||
}
|
||
|
||
test "compareSchwabSummary: null cash/total fields produce null deltas (within tolerance)" {
|
||
const allocator = std.testing.allocator;
|
||
const today = Date.fromYmd(2026, 5, 8);
|
||
|
||
const lots = [_]portfolio_mod.Lot{
|
||
.{
|
||
.symbol = "CASH",
|
||
.shares = 5000,
|
||
.open_date = Date.fromYmd(2024, 1, 1),
|
||
.open_price = 1.0,
|
||
.security_type = .cash,
|
||
.account = "X",
|
||
},
|
||
};
|
||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||
|
||
// Schwab summary missing cash + total fields (.cash = null, .total_value = null).
|
||
const schwab_accounts = [_]SchwabAccountSummary{
|
||
.{
|
||
.account_name = "X",
|
||
.account_number = "1234",
|
||
.cash = null,
|
||
.total_value = null,
|
||
},
|
||
};
|
||
|
||
var entries = [_]analysis.AccountTaxEntry{
|
||
.{
|
||
.account = "X",
|
||
.tax_type = .taxable,
|
||
.institution = "schwab",
|
||
.account_number = "1234",
|
||
},
|
||
};
|
||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
|
||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||
defer allocator.free(results);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||
try std.testing.expect(results[0].cash_delta == null);
|
||
try std.testing.expect(results[0].total_delta == null);
|
||
// Null deltas are treated as "ok" (no discrepancy possible to assert).
|
||
try std.testing.expect(!results[0].has_discrepancy);
|
||
}
|
||
|
||
test "compareSchwabSummary: today affects valuation of held assets" {
|
||
const allocator = std.testing.allocator;
|
||
|
||
// Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before
|
||
// open), it's not held → portfolio_total excludes it. With
|
||
// today=2025-01-01 (after open), portfolio_total includes 10 * price.
|
||
const lots = [_]portfolio_mod.Lot{
|
||
.{
|
||
.symbol = "AAPL",
|
||
.shares = 10,
|
||
.open_date = Date.fromYmd(2024, 6, 1),
|
||
.open_price = 150,
|
||
.account = "Acct",
|
||
},
|
||
};
|
||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||
|
||
const schwab_accounts = [_]SchwabAccountSummary{
|
||
.{
|
||
.account_name = "Acct",
|
||
.account_number = "1234",
|
||
.cash = 0,
|
||
.total_value = 2000,
|
||
},
|
||
};
|
||
|
||
var entries = [_]analysis.AccountTaxEntry{
|
||
.{
|
||
.account = "Acct",
|
||
.tax_type = .taxable,
|
||
.institution = "schwab",
|
||
.account_number = "1234",
|
||
},
|
||
};
|
||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
try prices.put("AAPL", 200.0);
|
||
|
||
// Before open: portfolio holds nothing for this account.
|
||
{
|
||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2024, 1, 1));
|
||
defer allocator.free(results);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_total, 0.01);
|
||
}
|
||
|
||
// After open: portfolio holds 10 * 200 = 2000.
|
||
{
|
||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1));
|
||
defer allocator.free(results);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01);
|
||
// Matches schwab → no discrepancy.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
||
try std.testing.expect(!results[0].has_discrepancy);
|
||
}
|
||
}
|