diff --git a/src/analytics/reconcile.zig b/src/analytics/reconcile.zig new file mode 100644 index 0000000..8c0706b --- /dev/null +++ b/src/analytics/reconcile.zig @@ -0,0 +1,34 @@ +//! Portfolio-vs-brokerage reconciliation: pure compute lifted out of +//! the `audit` command so it is unit-testable and reusable (finrev +//! consumes it too). The ANSI renderers stay in `commands/audit/`. +//! +//! - `common` broker-agnostic comparison engine (compareAccounts, +//! cdLotAllowance, findAbsentAccounts, ...) +//! - `schwab` Schwab positions CSV + summary reconcilers +//! - `fidelity` Fidelity positions CSV reconciler + +pub const common = @import("reconcile/common.zig"); +pub const schwab = @import("reconcile/schwab.zig"); +pub const fidelity = @import("reconcile/fidelity.zig"); + +// ── Flat convenience re-exports ────────────────────────────── +pub const value_tolerance = common.value_tolerance; +pub const cash_tolerance = common.cash_tolerance; +pub const AccountValueExpectation = common.AccountValueExpectation; +pub const SymbolComparison = common.SymbolComparison; +pub const AccountComparison = common.AccountComparison; +pub const AbsentAccount = common.AbsentAccount; +pub const cdLotAllowance = common.cdLotAllowance; +pub const accountValueExpectation = common.accountValueExpectation; +pub const compareAccounts = common.compareAccounts; +pub const hasAccountDiscrepancies = common.hasAccountDiscrepancies; +pub const presentNumbers = common.presentNumbers; +pub const findAbsentAccounts = common.findAbsentAccounts; + +pub const SchwabAccountComparison = schwab.SchwabAccountComparison; +pub const compareSchwabSummary = schwab.compareSchwabSummary; +pub const reconcileCsv = schwab.reconcileCsv; +pub const reconcileSummary = schwab.reconcileSummary; +pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies; + +pub const reconcileFidelity = fidelity.reconcile; diff --git a/src/analytics/reconcile/common.zig b/src/analytics/reconcile/common.zig new file mode 100644 index 0000000..08e370e --- /dev/null +++ b/src/analytics/reconcile/common.zig @@ -0,0 +1,1368 @@ +//! Broker-agnostic reconciliation engine (pure compute). +//! +//! Holds the pieces every per-account positions reconciler needs: the +//! normalized comparison types (`SymbolComparison` / `AccountComparison`), +//! the portfolio-vs-export comparator (`compareAccounts`, parameterized +//! by institution string), the price-provenance helper +//! (`resolvePositionValue`), CD/option value-expectation helpers, and +//! absent-account detection. +//! +//! Per-broker modules (`fidelity.zig`, `schwab.zig`) consume their +//! `brokerage/*` parser and feed the parsed positions into this engine. +//! The Schwab-summary path is the one genuinely broker-specific +//! reconciler and lives in `schwab.zig`. +//! +//! This is pure compute: no CLI, no color, no writer. The ANSI +//! renderers that consume these results live in `commands/audit/`, and +//! finrev consumes the same functions to propose portfolio edits. + +const std = @import("std"); +const zfin = @import("../../root.zig"); +const analysis = @import("../../analytics/analysis.zig"); +const brokerage_types = @import("../../brokerage/types.zig"); +const portfolio_mod = @import("../../models/portfolio.zig"); +const option = @import("../../models/option.zig"); +const Date = @import("../../Date.zig"); + +const BrokeragePosition = brokerage_types.BrokeragePosition; + +/// Reconciliation match tolerances. +/// +/// Securities get $1 of slack to absorb NAV-rounding on large +/// positions: a sub-cent per-share NAV difference between the broker +/// and zfin's fetched price on a six-figure mutual-fund position +/// easily exceeds a dollar, and that's not an actionable discrepancy. +/// +/// Cash is different - it has no NAV and no share count, it's an exact +/// dollar figure on both sides. It must match to the penny; the $1 +/// securities slack would otherwise silently hide real money-market +/// dividend accrual between updates (the whole point of the audit). +pub const value_tolerance: f64 = 1.0; +pub const cash_tolerance: f64 = 0.01; + +/// A CD's reconciliation allowance is capped at one year's coupon: +/// years-to-maturity is clamped to 1.0 so a long-dated CD doesn't open +/// an unbounded muting window. (Near-maturity CDs - the common case - +/// get a tight band; e.g. a $87k 3.8% CD 17 days out -> ~$154.) +pub const cd_allowance_year_cap: f64 = 1.0; +/// Fallback band (percent of face) for a CD that carries no `rate`. +/// Rare, but we still want a non-zero allowance so a rate-less CD isn't +/// flagged on every routine dealer mark. +pub const cd_allowance_fallback_pct: f64 = 0.5; + +/// Expected secondary-market mark on a single open CD, in dollars. +/// +/// zfin values a CD at face (`shares`); the broker marks it to the +/// secondary market, which drifts from face by a small, bounded amount +/// driven by the remaining coupon and time to maturity. We can't +/// reproduce the broker's exact mark without a live yield, but we can +/// bound it: `face x (rate/100) x years_to_maturity` (years capped at +/// 1.0). A value delta within this band is an expected CD mark (mute); +/// beyond it, something else is off (warn). Returns 0 for non-CD lots. +pub fn cdLotAllowance(lot: portfolio_mod.Lot, as_of: Date) f64 { + if (lot.security_type != .cd) return 0; + const face = @abs(lot.shares); + const rate = lot.rate orelse return face * (cd_allowance_fallback_pct / 100.0); + const years: f64 = if (lot.maturity_date) |m| blk: { + const y = Date.yearsBetween(as_of, m); + break :blk if (y < 0) 0 else @min(y, cd_allowance_year_cap); + } else cd_allowance_year_cap; + return face * (rate / 100.0) * years; +} + +/// Per-account, broker-agnostic classifier for "expected" value +/// differences between zfin and a brokerage. Reconcilers use it to +/// decide whether an account-level value delta should be muted +/// (expected) or warned (real discrepancy). +/// +/// - `has_options`: the account holds >= 1 open option lot. zfin +/// values options at cost (premium) while brokers mark them to +/// market, so the gap is real but unbounded and unknowable without +/// a live option quote. At the totals-only summary level any value +/// delta is therefore "expected" - mute it. (The per-position path +/// carves options out symbol-by-symbol instead; this is the +/// totals-only fallback.) +/// - `cd_allowance`: summed `cdLotAllowance` over the account's open +/// CDs - the bounded band within which a value delta is explained +/// by CD dealer marks. +pub const AccountValueExpectation = struct { + has_options: bool = false, + cd_allowance: f64 = 0, +}; + +/// Walk an account's open lots and summarize its expected value +/// differences. See `AccountValueExpectation`. +pub fn accountValueExpectation(portfolio: zfin.Portfolio, as_of: Date, account_name: []const u8) AccountValueExpectation { + var result: AccountValueExpectation = .{}; + for (portfolio.lots) |lot| { + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, account_name)) continue; + if (!lot.isOpen(as_of)) continue; + switch (lot.security_type) { + .option => result.has_options = true, + .cd => result.cd_allowance += cdLotAllowance(lot, as_of), + else => {}, + } + } + return result; +} + +/// 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), + }; +} + +// ── 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, + /// True when the matched portfolio lot(s) are a CD. A CD's value + /// delta is muted up to `cd_allowance`; beyond that it warns. + is_cd: bool = false, + /// Summed `cdLotAllowance` for the matched CD lot(s). Zero unless + /// `is_cd`. + cd_allowance: f64 = 0, + 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; + var is_cd = false; + var cd_allowance: f64 = 0; + + 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 + // (brokers export a compact symbol like "-AMZN260515C220" + // while the portfolio uses "AMZN 05/15/2026 220.00 C") + if (!std.mem.eql(u8, lot.symbol, bp.symbol) and + !option.symbolMatchesLot(bp.symbol, lot)) continue; + switch (lot.security_type) { + .cd => { + pf_shares += lot.shares; + pf_value += lot.shares; + pf_price = 1.0; + is_cd = true; + cd_allowance += cdLotAllowance(lot, as_of); + }, + .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; + // Cash matches to the penny; securities get $1 of NAV-rounding slack. + const tol: f64 = if (bp.is_cash) cash_tolerance else value_tolerance; + const value_match = if (value_delta) |d| @abs(d) < tol 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, + .is_cd = is_cd, + .cd_allowance = cd_allowance, + .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, + .is_cd = 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); +} + +/// Check if any account comparison results have discrepancies. +pub fn hasAccountDiscrepancies(results: []const AccountComparison) bool { + for (results) |r| { + if (r.has_discrepancies) return true; + } + return false; +} + +// ── Portfolio accounts absent from the export ──────────────── + +/// A portfolio account that maps to the institution under audit and +/// still holds open lots as-of, but whose account number never +/// appeared in the brokerage export. Surfaced as an advisory so an +/// account that was dropped from the download (or simply never +/// exported) doesn't reconcile silently. See `findAbsentAccounts`. +pub const AbsentAccount = struct { + /// Portfolio account name. Borrows from the account map. + account_name: []const u8, + /// Mapped account number. Borrows from the account map. + account_number: []const u8, + /// Current value of the account's open holdings as-of, for context. + portfolio_total: f64, +}; + +/// Collect the account numbers carried by a set of comparison +/// results. Monomorphized over the known result types that expose an +/// `account_number` field (`AccountComparison`, `SchwabAccountComparison`) +/// so the same membership input feeds `findAbsentAccounts` regardless +/// of which reconciler produced the results. Caller owns the slice; +/// the elements borrow from `results`. +pub fn presentNumbers(allocator: std.mem.Allocator, comptime T: type, results: []const T) ![][]const u8 { + var nums: std.ArrayList([]const u8) = .empty; + errdefer nums.deinit(allocator); + for (results) |r| try nums.append(allocator, r.account_number); + return nums.toOwnedSlice(allocator); +} + +/// Find portfolio accounts mapped to `institution` that still hold +/// open lots as-of but whose account number is absent from +/// `present_numbers` (the numbers that appeared in the brokerage +/// export). +/// +/// This closes a long-standing asymmetry: `compareAccounts` and +/// `compareSchwabSummary` walk export -> portfolio only, so an account +/// you hold that the export dropped (forgotten in the download, or +/// silently removed by the broker) reconciles to nothing and the +/// audit says nothing. Walking the other direction here surfaces it. +/// +/// Gating: only entries whose `institution` matches are considered, so +/// a Fidelity export never flags Schwab accounts. Suppression: +/// fully-closed / zero-balance accounts (no open lots as-of) are +/// skipped - a dropped account is only actionable if you still hold +/// something in it. Entries with no `account_number` are skipped too: +/// without a number they can't be matched to an export row anyway. +/// +/// Caller owns the returned slice. The string fields borrow from +/// `account_map`, which must outlive the result. +pub fn findAbsentAccounts( + allocator: std.mem.Allocator, + portfolio: zfin.Portfolio, + account_map: analysis.AccountMap, + institution: []const u8, + present_numbers: []const []const u8, + prices: std.StringHashMap(f64), + as_of: Date, +) ![]AbsentAccount { + var results: std.ArrayList(AbsentAccount) = .empty; + errdefer results.deinit(allocator); + + for (account_map.entries) |e| { + const inst = e.institution orelse continue; + if (!std.mem.eql(u8, inst, institution)) continue; + const num = e.account_number orelse continue; + + // Present in the export? The export -> portfolio pass covered it. + var present = false; + for (present_numbers) |pn| { + if (std.mem.eql(u8, pn, num)) { + present = true; + break; + } + } + if (present) continue; + + // Nothing held as-of -> nothing to reconcile. Suppress. + if (!portfolio.hasOpenLotsForAccount(as_of, e.account)) continue; + + try results.append(allocator, .{ + .account_name = e.account, + .account_number = num, + .portfolio_total = portfolio.totalForAccount(as_of, allocator, e.account, prices), + }); + } + + return results.toOwnedSlice(allocator); +} +// ── Tests ──────────────────────────────────────────────────── + +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); +} + +// ── 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 "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 = "Sample 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 "Sample IRA" + var entries = [_]analysis.AccountTaxEntry{ + .{ + .account = "Sample 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 "compareAccounts: sub-dollar cash drift is flagged (cash matches to the penny)" { + const allocator = std.testing.allocator; + + // Portfolio cash $38.75; Fidelity reports $38.97 - a $0.22 + // money-market dividend accrual. It's below the $1 securities + // tolerance, but cash carries no NAV rounding, so it must match to + // the penny rather than be silently swallowed. + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "FDRXX", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var brokerage = [_]BrokeragePosition{ + .{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDRXX", .description = "HELD IN MONEY MARKET", .quantity = null, .current_value = 38.97, .cost_basis = null, .is_cash = true }, + }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .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, "fidelity", prices, Date.fromYmd(2026, 6, 19)); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expect(results[0].has_discrepancies); + + var found_cash = false; + for (results[0].comparisons) |cmp| { + if (cmp.is_cash) { + found_cash = true; + try std.testing.expectApproxEqAbs(@as(f64, 0.22), cmp.value_delta.?, 0.001); + } + } + try std.testing.expect(found_cash); +} + +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)); +} + +// ── compareAccounts: branch coverage ───────────────────────── +// +// The two pre-existing compareAccounts tests cover option-delta +// tracking and sub-dollar cash drift. These pin the remaining +// structural branches: the unmapped-account path, the portfolio-only +// passes (stock, CD, option), and the CD-matched-to-broker-row path. + +test "compareAccounts: unmapped brokerage account is reported brokerage-only" { + const allocator = std.testing.allocator; + + // account_map has no entry for account "9999" -> findByInstitutionAccount + // returns null -> the portfolio_acct_name == null branch fires. + const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator }; + var entries = [_]analysis.AccountTaxEntry{}; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var brokerage = [_]BrokeragePosition{ + .{ .account_number = "9999", .account_name = "FIDELITY 9999", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false }, + }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 6, 19)); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + // Unmapped -> account_name resolves to "" via `orelse`. + try std.testing.expectEqualStrings("", results[0].account_name); + try std.testing.expect(results[0].has_discrepancies); + try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len); + const cmp = results[0].comparisons[0]; + try std.testing.expect(cmp.only_in_brokerage); + try std.testing.expectEqualStrings("AAPL", cmp.symbol); + try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].brokerage_total, 0.01); + // brokerage-only row carries a derived per-share price (value/qty). + try std.testing.expectApproxEqAbs(@as(f64, 200), cmp.brokerage_price.?, 0.01); +} + +test "compareAccounts: portfolio-only stock position is flagged only_in_portfolio" { + const allocator = std.testing.allocator; + + // Two stocks in the account; the broker export lists only AAPL, + // so MSFT must surface as portfolio-only. + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" }, + .{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300, .account = "Sample IRA" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var brokerage = [_]BrokeragePosition{ + .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false }, + }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("AAPL", 200.0); + try prices.put("MSFT", 320.0); + + const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 6, 19)); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + var found_msft_only = false; + for (results[0].comparisons) |cmp| { + if (std.mem.eql(u8, cmp.symbol, "MSFT")) { + try std.testing.expect(cmp.only_in_portfolio); + try std.testing.expect(!cmp.only_in_brokerage); + found_msft_only = true; + } + } + try std.testing.expect(found_msft_only); + try std.testing.expect(results[0].has_discrepancies); +} + +test "compareAccounts: portfolio-only CD and option lots are flagged" { + const allocator = std.testing.allocator; + + var lots = [_]portfolio_mod.Lot{ + // Matched stock so the account gets processed at all. + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" }, + // CD not present in the broker export -> portfolio-only CD path. + .{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" }, + // Option not present in the broker export -> portfolio-only option path. + .{ .symbol = "AMZN 05/15/2026 220.00 C", .security_type = .option, .underlying = "AMZN", .strike = 220, .option_type = .call, .maturity_date = Date.fromYmd(2026, 5, 15), .shares = -2, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 8.75, .multiplier = 100, .account = "Sample IRA" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var brokerage = [_]BrokeragePosition{ + .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false }, + }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("AAPL", 200.0); + + const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 3, 1)); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + var found_cd = false; + var found_opt = false; + for (results[0].comparisons) |cmp| { + if (std.mem.eql(u8, cmp.symbol, "CD-1234")) { + try std.testing.expect(cmp.only_in_portfolio); + try std.testing.expect(cmp.is_cash); // CDs render as cash-class rows + try std.testing.expectApproxEqAbs(@as(f64, 10000), cmp.portfolio_value, 0.01); + found_cd = true; + } + if (std.mem.eql(u8, cmp.symbol, "AMZN 05/15/2026 220.00 C")) { + try std.testing.expect(cmp.only_in_portfolio); + try std.testing.expect(cmp.is_option); + // |-2| * 8.75 * 100 = 1750 + try std.testing.expectApproxEqAbs(@as(f64, 1750), cmp.portfolio_value, 0.01); + found_opt = true; + } + } + try std.testing.expect(found_cd); + try std.testing.expect(found_opt); +} + +test "compareAccounts: a broker CD row matches a portfolio CD lot" { + const allocator = std.testing.allocator; + + // CD present on both sides with identical value -> exercises the + // `.cd` arm of the lot-match switch and lands on no discrepancy. + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var brokerage = [_]BrokeragePosition{ + .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "CD-1234", .description = "BANK CD", .quantity = 10000, .current_value = 10000, .cost_basis = 10000, .is_cash = false }, + }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 3, 1)); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len); + const cmp = results[0].comparisons[0]; + try std.testing.expectEqualStrings("CD-1234", cmp.symbol); + try std.testing.expectApproxEqAbs(@as(f64, 10000), cmp.portfolio_value, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 1.0), cmp.portfolio_price.?, 0.01); + try std.testing.expect(!results[0].has_discrepancies); +} + +test "cdLotAllowance: near-maturity CD yields a tight band" { + const as_of = Date.fromYmd(2026, 6, 28); + const cd = portfolio_mod.Lot{ + .symbol = "CDNEAR", + .security_type = .cd, + .shares = 87000, + .open_date = Date.fromYmd(2026, 2, 25), + .open_price = 1.0, + .rate = 3.8, + .maturity_date = Date.fromYmd(2026, 7, 15), + .account = "Sample IRA", + }; + // 17 days out: 87000 * 3.8% * (17/365.25) ~= $154. Tight enough to + // mute a dealer mark, far below a missing lot. + const band = cdLotAllowance(cd, as_of); + try std.testing.expect(band > 100 and band < 200); +} + +test "cdLotAllowance: long CD is capped at one year's coupon" { + const as_of = Date.fromYmd(2026, 6, 28); + const cd = portfolio_mod.Lot{ + .symbol = "CD5YR", + .security_type = .cd, + .shares = 100000, + .open_date = Date.fromYmd(2026, 1, 1), + .open_price = 1.0, + .rate = 4.0, + .maturity_date = Date.fromYmd(2031, 6, 28), + .account = "Sample IRA", + }; + // ~5 years out, but years clamp to 1.0 -> 100000 * 4% * 1.0 = 4000. + try std.testing.expectApproxEqAbs(@as(f64, 4000), cdLotAllowance(cd, as_of), 0.01); +} + +test "cdLotAllowance: rate-less CD falls back to a small percent of face" { + const as_of = Date.fromYmd(2026, 6, 28); + const cd = portfolio_mod.Lot{ + .symbol = "CDNORATE", + .security_type = .cd, + .shares = 10000, + .open_date = Date.fromYmd(2026, 1, 1), + .open_price = 1.0, + .maturity_date = Date.fromYmd(2026, 12, 31), + .account = "Sample IRA", + }; + // No rate -> 0.5% of $10,000 = $50. + try std.testing.expectApproxEqAbs(@as(f64, 50), cdLotAllowance(cd, as_of), 0.01); +} + +test "cdLotAllowance: matured CD and non-CD lots yield zero" { + const as_of = Date.fromYmd(2026, 6, 28); + // Maturity already passed -> years clamp to 0 -> no band. + const matured = portfolio_mod.Lot{ + .symbol = "CDOLD", + .security_type = .cd, + .shares = 50000, + .open_date = Date.fromYmd(2024, 1, 1), + .open_price = 1.0, + .rate = 5.0, + .maturity_date = Date.fromYmd(2026, 1, 1), + .account = "Sample IRA", + }; + try std.testing.expectApproxEqAbs(@as(f64, 0), cdLotAllowance(matured, as_of), 0.01); + + const stock = portfolio_mod.Lot{ + .symbol = "AAPL", + .shares = 10, + .open_date = Date.fromYmd(2024, 1, 1), + .open_price = 150, + .account = "Sample Brokerage", + }; + try std.testing.expectEqual(@as(f64, 0), cdLotAllowance(stock, as_of)); +} + +test "accountValueExpectation: CDs sum into a band, an open option flips the flag" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 28); + var lots = [_]portfolio_mod.Lot{ + // CD in Sample IRA, >1yr out so it caps at one year's coupon: 4% of 50000 = 2000. + .{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" }, + // Open option in Sample IRA -> has_options. + .{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" }, + // Plain stock in a different account. + .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample Brokerage" }, + // Matured CD in Sample IRA must be excluded (not open as-of). + .{ .symbol = "CDMAT", .security_type = .cd, .shares = 99999, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .rate = 9.0, .maturity_date = Date.fromYmd(2026, 1, 1), .account = "Sample IRA" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + const ira = accountValueExpectation(portfolio, as_of, "Sample IRA"); + try std.testing.expect(ira.has_options); + try std.testing.expectApproxEqAbs(@as(f64, 2000), ira.cd_allowance, 0.01); + + const brk = accountValueExpectation(portfolio, as_of, "Sample Brokerage"); + try std.testing.expect(!brk.has_options); + try std.testing.expectEqual(@as(f64, 0), brk.cd_allowance); +} + +test "compareAccounts: a CD's value delta carries is_cd and the bounded allowance" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 28); + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "CDX", .security_type = .cd, .shares = 100000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2026, 9, 1), .account = "Sample IRA" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + // Broker marks the CD $40 under face (~2mo out -> band is hundreds of $). + var brokerage = [_]BrokeragePosition{ + .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "CDX", .description = "BANK CD", .quantity = 100000, .current_value = 99960, .cost_basis = 100000, .is_cash = false }, + }; + const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, as_of); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len); + const cmp = results[0].comparisons[0]; + try std.testing.expect(cmp.is_cd); + try std.testing.expect(cmp.cd_allowance > 200); // the $40 mark sits well within + try std.testing.expectApproxEqAbs(@as(f64, -40), cmp.value_delta.?, 0.01); +} + +// ── Absent-account detection ───────────────────────────────── + +test "presentNumbers: collects account_number from each result" { + const allocator = std.testing.allocator; + + const results = [_]AccountComparison{ + .{ .account_name = "Sample IRA", .brokerage_name = "Fid", .account_number = "1234", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false }, + .{ .account_name = "Sample Brokerage", .brokerage_name = "Fid", .account_number = "5678", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false }, + }; + + const nums = try presentNumbers(allocator, AccountComparison, &results); + defer allocator.free(nums); + + try std.testing.expectEqual(@as(usize, 2), nums.len); + try std.testing.expectEqualStrings("1234", nums[0]); + try std.testing.expectEqualStrings("5678", nums[1]); +} + +test "findAbsentAccounts: flags held account missing from export; honors gating + closed-account suppression" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 19); + + var lots = [_]portfolio_mod.Lot{ + // fidelity #1234 -> held, absent from export -> SHOULD flag. + .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, + // fidelity #5678 -> held, present in export -> handled by main pass. + .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Sample Brokerage" }, + // fidelity #3456 -> only a closed lot, absent -> suppressed. + .{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 350.0, .account = "Sample Roth" }, + // schwab #9012 -> held, absent, but wrong institution for a Fidelity audit. + .{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "5678" }, + .{ .account = "Sample Roth", .tax_type = .roth, .institution = "fidelity", .account_number = "3456" }, + .{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" }, + // institution set but no account number -> can't match an export row -> skipped. + .{ .account = "Sample HSA", .tax_type = .hsa, .institution = "fidelity", .account_number = null }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("VTI", 210.0); + + // The Fidelity export contained only account #5678. + const present = [_][]const u8{"5678"}; + + const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "fidelity", &present, prices, as_of); + defer allocator.free(absent); + + try std.testing.expectEqual(@as(usize, 1), absent.len); + try std.testing.expectEqualStrings("Sample IRA", absent[0].account_name); + try std.testing.expectEqualStrings("1234", absent[0].account_number); + try std.testing.expectApproxEqAbs(@as(f64, 2100.0), absent[0].portfolio_total, 0.01); +} + +test "findAbsentAccounts: gating flags only the audited institution" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 19); + + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, + .{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" }, + .{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + // Auditing a Schwab export that matched no known account number: + // only the Schwab account surfaces; the Fidelity account is gated out. + const present = [_][]const u8{}; + const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "schwab", &present, prices, as_of); + defer allocator.free(absent); + + try std.testing.expectEqual(@as(usize, 1), absent.len); + try std.testing.expectEqualStrings("Schwab Trust", absent[0].account_name); + try std.testing.expectEqualStrings("9012", absent[0].account_number); +} + +test "findAbsentAccounts: no absent accounts when export covers every held account" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 19); + + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const present = [_][]const u8{"1234"}; + const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "fidelity", &present, prices, as_of); + defer allocator.free(absent); + + try std.testing.expectEqual(@as(usize, 0), absent.len); +} diff --git a/src/commands/audit/fidelity.zig b/src/analytics/reconcile/fidelity.zig similarity index 96% rename from src/commands/audit/fidelity.zig rename to src/analytics/reconcile/fidelity.zig index ac2e586..573666c 100644 --- a/src/commands/audit/fidelity.zig +++ b/src/analytics/reconcile/fidelity.zig @@ -1,12 +1,12 @@ -//! Fidelity reconciler for the `audit` command. +//! Fidelity reconciler (pure compute). //! //! Fidelity exports a single "all accounts" positions CSV. Parsing //! lives in `brokerage/fidelity.zig`; this module wires that parser //! into the shared per-account comparison engine in `common.zig`. //! Because the Fidelity export is a plain per-account positions list, //! the only Fidelity-specific knowledge here is "use the Fidelity CSV -//! parser" and "the institution key is `fidelity`" - everything else -//! (comparison, display) is shared. +//! parser" and "the institution key is `fidelity`" - the comparison is +//! shared, and the ANSI display lives in `commands/audit/`. const std = @import("std"); const zfin = @import("../../root.zig"); diff --git a/src/analytics/reconcile/schwab.zig b/src/analytics/reconcile/schwab.zig new file mode 100644 index 0000000..60e1746 --- /dev/null +++ b/src/analytics/reconcile/schwab.zig @@ -0,0 +1,555 @@ +//! Schwab reconcilers (pure compute). +//! +//! Schwab has two export shapes, so this module carries more than +//! the Fidelity one: +//! +//! 1. **Per-account positions CSV** (`--schwab`) - same per-account +//! positions shape Fidelity uses, so it feeds the shared +//! `common.compareAccounts` engine via `reconcileCsv`. +//! 2. **Account summary paste** (`--schwab-summary`) - a +//! per-account totals-only view with no per-symbol detail. This +//! is the one genuinely broker-specific reconciler, with its own +//! comparison type (`SchwabAccountComparison`) and comparator +//! (`compareSchwabSummary`). +//! +//! Parsing lives in `brokerage/schwab.zig`; the ANSI renderers live in +//! `commands/audit/schwab.zig`. + +const std = @import("std"); +const zfin = @import("../../root.zig"); +const analysis = @import("../../analytics/analysis.zig"); +const Date = @import("../../Date.zig"); +const common = @import("common.zig"); +const schwab_parser = @import("../../brokerage/schwab.zig"); + +const AccountSummary = schwab_parser.AccountSummary; + +/// 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, + /// Account holds open options: zfin values them at cost, the broker + /// at market, so a totals-level value delta is expected (mute it). + has_options: bool = false, + /// Bounded band within which a value delta is an expected CD mark. + cd_allowance: f64 = 0, + has_discrepancy: bool, +}; + +// ── Per-account positions CSV (--schwab) ───────────────────── + +/// Parse a Schwab per-account positions CSV and reconcile it against +/// the portfolio via the shared engine. Returns owned +/// `AccountComparison` results (free each `.comparisons` slice, then +/// the results slice). String fields borrow from `csv_data`, which +/// must outlive them. Propagates parser and allocation errors. +pub fn reconcileCsv( + allocator: std.mem.Allocator, + portfolio: zfin.Portfolio, + csv_data: []const u8, + account_map: analysis.AccountMap, + prices: std.StringHashMap(f64), + as_of: Date, +) ![]common.AccountComparison { + const parsed = try schwab_parser.parseCsv(allocator, csv_data); + // Result strings borrow from `csv_data`, not the positions slice, + // so freeing the slice array here is safe. + defer allocator.free(parsed.positions); + return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of); +} + +// ── Account summary paste (--schwab-summary) ───────────────── + +/// Parse a Schwab account summary and reconcile its per-account +/// totals against portfolio.srf. Returns owned results (free the +/// slice). String fields borrow from `summary_data`, which must +/// outlive them. Propagates parser (`NoAccountsFound`) and +/// allocation errors. +pub fn reconcileSummary( + allocator: std.mem.Allocator, + portfolio: zfin.Portfolio, + summary_data: []const u8, + account_map: analysis.AccountMap, + prices: std.StringHashMap(f64), + as_of: Date, +) ![]SchwabAccountComparison { + const schwab_accounts = try schwab_parser.parseSummary(allocator, summary_data); + defer allocator.free(schwab_accounts); + return compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of); +} + +/// Compare Schwab summary against portfolio.srf account totals. +pub fn compareSchwabSummary( + allocator: std.mem.Allocator, + portfolio: zfin.Portfolio, + schwab_accounts: []const AccountSummary, + 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; + var expectation: common.AccountValueExpectation = .{}; + + if (portfolio_acct) |pa| { + pf_cash = portfolio.cashForAccount(pa); + pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices); + expectation = common.accountValueExpectation(portfolio, as_of, pa); + } + + 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) < common.cash_tolerance else true; + const total_ok = if (total_delta) |d| @abs(d) < common.value_tolerance 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_options = expectation.has_options, + .cd_allowance = expectation.cd_allowance, + // A value delta is a discrepancy regardless of cause: an + // audit must never hide it. Muting (below) is a display + // hint, not a suppression - so visibility stays keyed on the + // raw tolerance, same as before. + .has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null, + }); + } + + return results.toOwnedSlice(allocator); +} + +/// Check if any Schwab summary results have discrepancies. +pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool { + for (results) |r| { + if (r.has_discrepancy) return true; + } + return false; +} + +// ── Tests ──────────────────────────────────────────────────── + +const portfolio_mod = @import("../../models/portfolio.zig"); + +test "hasSchwabDiscrepancies" { + const clean = [_]SchwabAccountComparison{.{ + .account_name = "IRA", + .schwab_name = "Roth IRA", + .account_number = "1234", + .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 = "1234", + .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 "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 = "Sample Brokerage", + }, + .{ + .symbol = "AAPL", + .shares = 10, + .open_date = Date.fromYmd(2024, 1, 1), + .open_price = 150, + .account = "Sample Brokerage", + }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; + + const schwab_accounts = [_]AccountSummary{ + .{ + .account_name = "Sample Brokerage", + .account_number = "1234", + .cash = 5000.0, + .total_value = 7000.0, + }, + }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ + .account = "Sample 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("Sample 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 = [_]AccountSummary{ + .{ + .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: sub-dollar cash drift is flagged (cash matches to the penny)" { + const allocator = std.testing.allocator; + const today = Date.fromYmd(2026, 6, 19); + + // Portfolio cash $38.75; Schwab reports $38.97 - a $0.22 accrual. + // Below the $1 securities tolerance, but a real cash drift that + // must surface. + const lots = [_]portfolio_mod.Lot{ + .{ .symbol = "CASH", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; + + const schwab_accounts = [_]AccountSummary{ + .{ .account_name = "Sample Brokerage", .account_number = "1234", .cash = 38.97, .total_value = 38.97 }, + }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample 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, 0.22), results[0].cash_delta.?, 0.001); + 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 = [_]AccountSummary{ + .{ + .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 = [_]AccountSummary{ + .{ + .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 = [_]AccountSummary{ + .{ + .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); + } +} + +// ── reconcile wrappers (parse + compare wiring) ────────────── + +test "reconcileCsv: parses a Schwab positions CSV and reconciles it" { + const allocator = std.testing.allocator; + const csv = + "\"Positions for account Sample Trust ...1234 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"; + + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AMZN", .shares = 1488, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 74, .account = "Sample Trust" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample Trust", .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("AMZN", 239.208); + + const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 4, 10)); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expectEqualStrings("Sample Trust", results[0].account_name); + var found_amzn = false; + for (results[0].comparisons) |c| { + if (std.mem.eql(u8, c.symbol, "AMZN")) found_amzn = true; + } + try std.testing.expect(found_amzn); +} + +test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" { + const allocator = std.testing.allocator; + const data = + \\Sample Roth + \\Account number ending in 1234 ...1234 + \\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47% + \\Sample Inherited IRA + \\Account number ending in 5678 ...5678 + \\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73% + ; + const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator }; + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample Roth 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 reconcileSummary(allocator, portfolio, data, acct_map, prices, Date.fromYmd(2026, 4, 10)); + defer allocator.free(results); + + try std.testing.expectEqual(@as(usize, 2), results.len); + // 1234 maps; 5678 is absent from the map -> unmapped (empty name). + try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name); + try std.testing.expectEqualStrings("", results[1].account_name); +} + +test "compareSchwabSummary: populates option flag and CD allowance from the portfolio" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 28); + var lots = [_]portfolio_mod.Lot{ + // CD >1yr out -> caps at one year's coupon: 4% of 50000 = 2000. + .{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" }, + // Open option -> has_options. + .{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + var summary = [_]AccountSummary{ + .{ .account_name = "IRA", .account_number = "1234", .cash = 0, .total_value = 60000 }, + }; + + const results = try compareSchwabSummary(allocator, portfolio, &summary, acct_map, prices, as_of); + defer allocator.free(results); + + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expect(results[0].has_options); + try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].cd_allowance, 0.01); +} diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 47c5b64..3701015 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -22,7 +22,7 @@ const cli = @import("common.zig"); const framework = @import("framework.zig"); const common = @import("audit/common.zig"); -const fidelity = @import("audit/fidelity.zig"); +const fidelity = @import("../analytics/reconcile/fidelity.zig"); const schwab = @import("audit/schwab.zig"); const hygiene = @import("audit/hygiene.zig"); diff --git a/src/commands/audit/common.zig b/src/commands/audit/common.zig index fedf776..d3cf7bb 100644 --- a/src/commands/audit/common.zig +++ b/src/commands/audit/common.zig @@ -1,553 +1,32 @@ -//! Shared reconciliation engine for the `audit` command. +//! Display/rendering for the broker-agnostic `audit` output. //! -//! Holds the broker-agnostic pieces every per-account positions -//! reconciler needs: the normalized comparison types -//! (`SymbolComparison` / `AccountComparison`), the -//! portfolio-vs-export comparator (`compareAccounts`, parameterized -//! by institution string), the price-provenance helper -//! (`resolvePositionValue`), and the CSV-style display -//! (`displayResults` / `displayRatioSuggestions`). -//! -//! Per-broker modules (`fidelity.zig`, `schwab.zig`) consume their -//! `brokerage/*` parser and feed the parsed positions into this -//! engine. The Schwab-summary path is the one genuinely -//! broker-specific reconciler and lives in `schwab.zig`. +//! Reconciliation *compute* now lives in `analytics/reconcile/` (pure, +//! testable, reusable by finrev). This file keeps only the ANSI-table +//! renderers, plus back-compat re-exports so `audit.zig` and +//! `hygiene.zig` keep addressing the API as `common.*`. const std = @import("std"); const zfin = @import("../../root.zig"); const cli = @import("../common.zig"); const Money = @import("../../Money.zig"); const analysis = @import("../../analytics/analysis.zig"); -const brokerage_types = @import("../../brokerage/types.zig"); const portfolio_mod = @import("../../models/portfolio.zig"); -const option = @import("../../models/option.zig"); const Date = @import("../../Date.zig"); +const reconcile = @import("../../analytics/reconcile.zig"); -const BrokeragePosition = brokerage_types.BrokeragePosition; - -/// Reconciliation match tolerances. -/// -/// Securities get $1 of slack to absorb NAV-rounding on large -/// positions: a sub-cent per-share NAV difference between the broker -/// and zfin's fetched price on a six-figure mutual-fund position -/// easily exceeds a dollar, and that's not an actionable discrepancy. -/// -/// Cash is different - it has no NAV and no share count, it's an exact -/// dollar figure on both sides. It must match to the penny; the $1 -/// securities slack would otherwise silently hide real money-market -/// dividend accrual between updates (the whole point of the audit). -pub const value_tolerance: f64 = 1.0; -pub const cash_tolerance: f64 = 0.01; - -/// A CD's reconciliation allowance is capped at one year's coupon: -/// years-to-maturity is clamped to 1.0 so a long-dated CD doesn't open -/// an unbounded muting window. (Near-maturity CDs - the common case - -/// get a tight band; e.g. a $87k 3.8% CD 17 days out -> ~$154.) -pub const cd_allowance_year_cap: f64 = 1.0; -/// Fallback band (percent of face) for a CD that carries no `rate`. -/// Rare, but we still want a non-zero allowance so a rate-less CD isn't -/// flagged on every routine dealer mark. -pub const cd_allowance_fallback_pct: f64 = 0.5; - -/// Expected secondary-market mark on a single open CD, in dollars. -/// -/// zfin values a CD at face (`shares`); the broker marks it to the -/// secondary market, which drifts from face by a small, bounded amount -/// driven by the remaining coupon and time to maturity. We can't -/// reproduce the broker's exact mark without a live yield, but we can -/// bound it: `face x (rate/100) x years_to_maturity` (years capped at -/// 1.0). A value delta within this band is an expected CD mark (mute); -/// beyond it, something else is off (warn). Returns 0 for non-CD lots. -pub fn cdLotAllowance(lot: portfolio_mod.Lot, as_of: Date) f64 { - if (lot.security_type != .cd) return 0; - const face = @abs(lot.shares); - const rate = lot.rate orelse return face * (cd_allowance_fallback_pct / 100.0); - const years: f64 = if (lot.maturity_date) |m| blk: { - const y = Date.yearsBetween(as_of, m); - break :blk if (y < 0) 0 else @min(y, cd_allowance_year_cap); - } else cd_allowance_year_cap; - return face * (rate / 100.0) * years; -} - -/// Per-account, broker-agnostic classifier for "expected" value -/// differences between zfin and a brokerage. Reconcilers use it to -/// decide whether an account-level value delta should be muted -/// (expected) or warned (real discrepancy). -/// -/// - `has_options`: the account holds >= 1 open option lot. zfin -/// values options at cost (premium) while brokers mark them to -/// market, so the gap is real but unbounded and unknowable without -/// a live option quote. At the totals-only summary level any value -/// delta is therefore "expected" - mute it. (The per-position path -/// carves options out symbol-by-symbol instead; this is the -/// totals-only fallback.) -/// - `cd_allowance`: summed `cdLotAllowance` over the account's open -/// CDs - the bounded band within which a value delta is explained -/// by CD dealer marks. -pub const AccountValueExpectation = struct { - has_options: bool = false, - cd_allowance: f64 = 0, -}; - -/// Walk an account's open lots and summarize its expected value -/// differences. See `AccountValueExpectation`. -pub fn accountValueExpectation(portfolio: zfin.Portfolio, as_of: Date, account_name: []const u8) AccountValueExpectation { - var result: AccountValueExpectation = .{}; - for (portfolio.lots) |lot| { - const lot_acct = lot.account orelse continue; - if (!std.mem.eql(u8, lot_acct, account_name)) continue; - if (!lot.isOpen(as_of)) continue; - switch (lot.security_type) { - .option => result.has_options = true, - .cd => result.cd_allowance += cdLotAllowance(lot, as_of), - else => {}, - } - } - return result; -} - -/// 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), - }; -} - -// ── 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, - /// True when the matched portfolio lot(s) are a CD. A CD's value - /// delta is muted up to `cd_allowance`; beyond that it warns. - is_cd: bool = false, - /// Summed `cdLotAllowance` for the matched CD lot(s). Zero unless - /// `is_cd`. - cd_allowance: f64 = 0, - 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; - var is_cd = false; - var cd_allowance: f64 = 0; - - 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 - // (brokers export a compact symbol like "-AMZN260515C220" - // while the portfolio uses "AMZN 05/15/2026 220.00 C") - if (!std.mem.eql(u8, lot.symbol, bp.symbol) and - !option.symbolMatchesLot(bp.symbol, lot)) continue; - switch (lot.security_type) { - .cd => { - pf_shares += lot.shares; - pf_value += lot.shares; - pf_price = 1.0; - is_cd = true; - cd_allowance += cdLotAllowance(lot, as_of); - }, - .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; - // Cash matches to the penny; securities get $1 of NAV-rounding slack. - const tol: f64 = if (bp.is_cash) cash_tolerance else value_tolerance; - const value_match = if (value_delta) |d| @abs(d) < tol 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, - .is_cd = is_cd, - .cd_allowance = cd_allowance, - .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, - .is_cd = 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); -} +// ── Back-compat re-exports of the reconciliation compute API ── +pub const value_tolerance = reconcile.common.value_tolerance; +pub const cash_tolerance = reconcile.common.cash_tolerance; +pub const AccountValueExpectation = reconcile.AccountValueExpectation; +pub const cdLotAllowance = reconcile.cdLotAllowance; +pub const accountValueExpectation = reconcile.accountValueExpectation; +pub const SymbolComparison = reconcile.SymbolComparison; +pub const AccountComparison = reconcile.AccountComparison; +pub const compareAccounts = reconcile.compareAccounts; +pub const hasAccountDiscrepancies = reconcile.hasAccountDiscrepancies; +pub const AbsentAccount = reconcile.AbsentAccount; +pub const presentNumbers = reconcile.presentNumbers; +pub const findAbsentAccounts = reconcile.findAbsentAccounts; // ── Ratio suggestions ──────────────────────────────────────── @@ -851,104 +330,6 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std } try out.print("\n", .{}); } - -/// Check if any account comparison results have discrepancies. -pub fn hasAccountDiscrepancies(results: []const AccountComparison) bool { - for (results) |r| { - if (r.has_discrepancies) return true; - } - return false; -} - -// ── Portfolio accounts absent from the export ──────────────── - -/// A portfolio account that maps to the institution under audit and -/// still holds open lots as-of, but whose account number never -/// appeared in the brokerage export. Surfaced as an advisory so an -/// account that was dropped from the download (or simply never -/// exported) doesn't reconcile silently. See `findAbsentAccounts`. -pub const AbsentAccount = struct { - /// Portfolio account name. Borrows from the account map. - account_name: []const u8, - /// Mapped account number. Borrows from the account map. - account_number: []const u8, - /// Current value of the account's open holdings as-of, for context. - portfolio_total: f64, -}; - -/// Collect the account numbers carried by a set of comparison -/// results. Monomorphized over the known result types that expose an -/// `account_number` field (`AccountComparison`, `SchwabAccountComparison`) -/// so the same membership input feeds `findAbsentAccounts` regardless -/// of which reconciler produced the results. Caller owns the slice; -/// the elements borrow from `results`. -pub fn presentNumbers(allocator: std.mem.Allocator, comptime T: type, results: []const T) ![][]const u8 { - var nums: std.ArrayList([]const u8) = .empty; - errdefer nums.deinit(allocator); - for (results) |r| try nums.append(allocator, r.account_number); - return nums.toOwnedSlice(allocator); -} - -/// Find portfolio accounts mapped to `institution` that still hold -/// open lots as-of but whose account number is absent from -/// `present_numbers` (the numbers that appeared in the brokerage -/// export). -/// -/// This closes a long-standing asymmetry: `compareAccounts` and -/// `compareSchwabSummary` walk export -> portfolio only, so an account -/// you hold that the export dropped (forgotten in the download, or -/// silently removed by the broker) reconciles to nothing and the -/// audit says nothing. Walking the other direction here surfaces it. -/// -/// Gating: only entries whose `institution` matches are considered, so -/// a Fidelity export never flags Schwab accounts. Suppression: -/// fully-closed / zero-balance accounts (no open lots as-of) are -/// skipped - a dropped account is only actionable if you still hold -/// something in it. Entries with no `account_number` are skipped too: -/// without a number they can't be matched to an export row anyway. -/// -/// Caller owns the returned slice. The string fields borrow from -/// `account_map`, which must outlive the result. -pub fn findAbsentAccounts( - allocator: std.mem.Allocator, - portfolio: zfin.Portfolio, - account_map: analysis.AccountMap, - institution: []const u8, - present_numbers: []const []const u8, - prices: std.StringHashMap(f64), - as_of: Date, -) ![]AbsentAccount { - var results: std.ArrayList(AbsentAccount) = .empty; - errdefer results.deinit(allocator); - - for (account_map.entries) |e| { - const inst = e.institution orelse continue; - if (!std.mem.eql(u8, inst, institution)) continue; - const num = e.account_number orelse continue; - - // Present in the export? The export -> portfolio pass covered it. - var present = false; - for (present_numbers) |pn| { - if (std.mem.eql(u8, pn, num)) { - present = true; - break; - } - } - if (present) continue; - - // Nothing held as-of -> nothing to reconcile. Suppress. - if (!portfolio.hasOpenLotsForAccount(as_of, e.account)) continue; - - try results.append(allocator, .{ - .account_name = e.account, - .account_number = num, - .portfolio_total = portfolio.totalForAccount(as_of, allocator, e.account, prices), - }); - } - - return results.toOwnedSlice(allocator); -} - /// Render the "portfolio accounts not found in " advisory. /// Silent when `absent` is empty, so it composes cleanly after both /// the verbose audit table and the compact "no discrepancies" line. @@ -971,614 +352,6 @@ pub fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, scope_p } try out.print("\n", .{}); } - -// ── Tests ──────────────────────────────────────────────────── - -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); -} - -// ── 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 "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 = "Sample 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 "Sample IRA" - var entries = [_]analysis.AccountTaxEntry{ - .{ - .account = "Sample 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 "compareAccounts: sub-dollar cash drift is flagged (cash matches to the penny)" { - const allocator = std.testing.allocator; - - // Portfolio cash $38.75; Fidelity reports $38.97 - a $0.22 - // money-market dividend accrual. It's below the $1 securities - // tolerance, but cash carries no NAV rounding, so it must match to - // the penny rather than be silently swallowed. - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "FDRXX", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - - var brokerage = [_]BrokeragePosition{ - .{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDRXX", .description = "HELD IN MONEY MARKET", .quantity = null, .current_value = 38.97, .cost_basis = null, .is_cash = true }, - }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .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, "fidelity", prices, Date.fromYmd(2026, 6, 19)); - defer { - for (results) |r| allocator.free(r.comparisons); - allocator.free(results); - } - - try std.testing.expectEqual(@as(usize, 1), results.len); - try std.testing.expect(results[0].has_discrepancies); - - var found_cash = false; - for (results[0].comparisons) |cmp| { - if (cmp.is_cash) { - found_cash = true; - try std.testing.expectApproxEqAbs(@as(f64, 0.22), cmp.value_delta.?, 0.001); - } - } - try std.testing.expect(found_cash); -} - -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)); -} - -// ── compareAccounts: branch coverage ───────────────────────── -// -// The two pre-existing compareAccounts tests cover option-delta -// tracking and sub-dollar cash drift. These pin the remaining -// structural branches: the unmapped-account path, the portfolio-only -// passes (stock, CD, option), and the CD-matched-to-broker-row path. - -test "compareAccounts: unmapped brokerage account is reported brokerage-only" { - const allocator = std.testing.allocator; - - // account_map has no entry for account "9999" -> findByInstitutionAccount - // returns null -> the portfolio_acct_name == null branch fires. - const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator }; - var entries = [_]analysis.AccountTaxEntry{}; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - - var brokerage = [_]BrokeragePosition{ - .{ .account_number = "9999", .account_name = "FIDELITY 9999", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false }, - }; - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - - const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 6, 19)); - defer { - for (results) |r| allocator.free(r.comparisons); - allocator.free(results); - } - - try std.testing.expectEqual(@as(usize, 1), results.len); - // Unmapped -> account_name resolves to "" via `orelse`. - try std.testing.expectEqualStrings("", results[0].account_name); - try std.testing.expect(results[0].has_discrepancies); - try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len); - const cmp = results[0].comparisons[0]; - try std.testing.expect(cmp.only_in_brokerage); - try std.testing.expectEqualStrings("AAPL", cmp.symbol); - try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].brokerage_total, 0.01); - // brokerage-only row carries a derived per-share price (value/qty). - try std.testing.expectApproxEqAbs(@as(f64, 200), cmp.brokerage_price.?, 0.01); -} - -test "compareAccounts: portfolio-only stock position is flagged only_in_portfolio" { - const allocator = std.testing.allocator; - - // Two stocks in the account; the broker export lists only AAPL, - // so MSFT must surface as portfolio-only. - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" }, - .{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300, .account = "Sample IRA" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" }, - }; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - - var brokerage = [_]BrokeragePosition{ - .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false }, - }; - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - try prices.put("AAPL", 200.0); - try prices.put("MSFT", 320.0); - - const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 6, 19)); - defer { - for (results) |r| allocator.free(r.comparisons); - allocator.free(results); - } - - try std.testing.expectEqual(@as(usize, 1), results.len); - var found_msft_only = false; - for (results[0].comparisons) |cmp| { - if (std.mem.eql(u8, cmp.symbol, "MSFT")) { - try std.testing.expect(cmp.only_in_portfolio); - try std.testing.expect(!cmp.only_in_brokerage); - found_msft_only = true; - } - } - try std.testing.expect(found_msft_only); - try std.testing.expect(results[0].has_discrepancies); -} - -test "compareAccounts: portfolio-only CD and option lots are flagged" { - const allocator = std.testing.allocator; - - var lots = [_]portfolio_mod.Lot{ - // Matched stock so the account gets processed at all. - .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" }, - // CD not present in the broker export -> portfolio-only CD path. - .{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" }, - // Option not present in the broker export -> portfolio-only option path. - .{ .symbol = "AMZN 05/15/2026 220.00 C", .security_type = .option, .underlying = "AMZN", .strike = 220, .option_type = .call, .maturity_date = Date.fromYmd(2026, 5, 15), .shares = -2, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 8.75, .multiplier = 100, .account = "Sample IRA" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" }, - }; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - - var brokerage = [_]BrokeragePosition{ - .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false }, - }; - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - try prices.put("AAPL", 200.0); - - const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 3, 1)); - defer { - for (results) |r| allocator.free(r.comparisons); - allocator.free(results); - } - - var found_cd = false; - var found_opt = false; - for (results[0].comparisons) |cmp| { - if (std.mem.eql(u8, cmp.symbol, "CD-1234")) { - try std.testing.expect(cmp.only_in_portfolio); - try std.testing.expect(cmp.is_cash); // CDs render as cash-class rows - try std.testing.expectApproxEqAbs(@as(f64, 10000), cmp.portfolio_value, 0.01); - found_cd = true; - } - if (std.mem.eql(u8, cmp.symbol, "AMZN 05/15/2026 220.00 C")) { - try std.testing.expect(cmp.only_in_portfolio); - try std.testing.expect(cmp.is_option); - // |-2| * 8.75 * 100 = 1750 - try std.testing.expectApproxEqAbs(@as(f64, 1750), cmp.portfolio_value, 0.01); - found_opt = true; - } - } - try std.testing.expect(found_cd); - try std.testing.expect(found_opt); -} - -test "compareAccounts: a broker CD row matches a portfolio CD lot" { - const allocator = std.testing.allocator; - - // CD present on both sides with identical value -> exercises the - // `.cd` arm of the lot-match switch and lands on no discrepancy. - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" }, - }; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - - var brokerage = [_]BrokeragePosition{ - .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "CD-1234", .description = "BANK CD", .quantity = 10000, .current_value = 10000, .cost_basis = 10000, .is_cash = false }, - }; - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - - const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 3, 1)); - defer { - for (results) |r| allocator.free(r.comparisons); - allocator.free(results); - } - - try std.testing.expectEqual(@as(usize, 1), results.len); - try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len); - const cmp = results[0].comparisons[0]; - try std.testing.expectEqualStrings("CD-1234", cmp.symbol); - try std.testing.expectApproxEqAbs(@as(f64, 10000), cmp.portfolio_value, 0.01); - try std.testing.expectApproxEqAbs(@as(f64, 1.0), cmp.portfolio_price.?, 0.01); - try std.testing.expect(!results[0].has_discrepancies); -} - -test "cdLotAllowance: near-maturity CD yields a tight band" { - const as_of = Date.fromYmd(2026, 6, 28); - const cd = portfolio_mod.Lot{ - .symbol = "CDNEAR", - .security_type = .cd, - .shares = 87000, - .open_date = Date.fromYmd(2026, 2, 25), - .open_price = 1.0, - .rate = 3.8, - .maturity_date = Date.fromYmd(2026, 7, 15), - .account = "Sample IRA", - }; - // 17 days out: 87000 * 3.8% * (17/365.25) ~= $154. Tight enough to - // mute a dealer mark, far below a missing lot. - const band = cdLotAllowance(cd, as_of); - try std.testing.expect(band > 100 and band < 200); -} - -test "cdLotAllowance: long CD is capped at one year's coupon" { - const as_of = Date.fromYmd(2026, 6, 28); - const cd = portfolio_mod.Lot{ - .symbol = "CD5YR", - .security_type = .cd, - .shares = 100000, - .open_date = Date.fromYmd(2026, 1, 1), - .open_price = 1.0, - .rate = 4.0, - .maturity_date = Date.fromYmd(2031, 6, 28), - .account = "Sample IRA", - }; - // ~5 years out, but years clamp to 1.0 -> 100000 * 4% * 1.0 = 4000. - try std.testing.expectApproxEqAbs(@as(f64, 4000), cdLotAllowance(cd, as_of), 0.01); -} - -test "cdLotAllowance: rate-less CD falls back to a small percent of face" { - const as_of = Date.fromYmd(2026, 6, 28); - const cd = portfolio_mod.Lot{ - .symbol = "CDNORATE", - .security_type = .cd, - .shares = 10000, - .open_date = Date.fromYmd(2026, 1, 1), - .open_price = 1.0, - .maturity_date = Date.fromYmd(2026, 12, 31), - .account = "Sample IRA", - }; - // No rate -> 0.5% of $10,000 = $50. - try std.testing.expectApproxEqAbs(@as(f64, 50), cdLotAllowance(cd, as_of), 0.01); -} - -test "cdLotAllowance: matured CD and non-CD lots yield zero" { - const as_of = Date.fromYmd(2026, 6, 28); - // Maturity already passed -> years clamp to 0 -> no band. - const matured = portfolio_mod.Lot{ - .symbol = "CDOLD", - .security_type = .cd, - .shares = 50000, - .open_date = Date.fromYmd(2024, 1, 1), - .open_price = 1.0, - .rate = 5.0, - .maturity_date = Date.fromYmd(2026, 1, 1), - .account = "Sample IRA", - }; - try std.testing.expectApproxEqAbs(@as(f64, 0), cdLotAllowance(matured, as_of), 0.01); - - const stock = portfolio_mod.Lot{ - .symbol = "AAPL", - .shares = 10, - .open_date = Date.fromYmd(2024, 1, 1), - .open_price = 150, - .account = "Sample Brokerage", - }; - try std.testing.expectEqual(@as(f64, 0), cdLotAllowance(stock, as_of)); -} - -test "accountValueExpectation: CDs sum into a band, an open option flips the flag" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 28); - var lots = [_]portfolio_mod.Lot{ - // CD in Sample IRA, >1yr out so it caps at one year's coupon: 4% of 50000 = 2000. - .{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" }, - // Open option in Sample IRA -> has_options. - .{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" }, - // Plain stock in a different account. - .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample Brokerage" }, - // Matured CD in Sample IRA must be excluded (not open as-of). - .{ .symbol = "CDMAT", .security_type = .cd, .shares = 99999, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .rate = 9.0, .maturity_date = Date.fromYmd(2026, 1, 1), .account = "Sample IRA" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - - const ira = accountValueExpectation(portfolio, as_of, "Sample IRA"); - try std.testing.expect(ira.has_options); - try std.testing.expectApproxEqAbs(@as(f64, 2000), ira.cd_allowance, 0.01); - - const brk = accountValueExpectation(portfolio, as_of, "Sample Brokerage"); - try std.testing.expect(!brk.has_options); - try std.testing.expectEqual(@as(f64, 0), brk.cd_allowance); -} - -test "compareAccounts: a CD's value delta carries is_cd and the bounded allowance" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 28); - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "CDX", .security_type = .cd, .shares = 100000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2026, 9, 1), .account = "Sample IRA" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" }, - }; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - - // Broker marks the CD $40 under face (~2mo out -> band is hundreds of $). - var brokerage = [_]BrokeragePosition{ - .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "CDX", .description = "BANK CD", .quantity = 100000, .current_value = 99960, .cost_basis = 100000, .is_cash = false }, - }; - const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, as_of); - defer { - for (results) |r| allocator.free(r.comparisons); - allocator.free(results); - } - try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len); - const cmp = results[0].comparisons[0]; - try std.testing.expect(cmp.is_cd); - try std.testing.expect(cmp.cd_allowance > 200); // the $40 mark sits well within - try std.testing.expectApproxEqAbs(@as(f64, -40), cmp.value_delta.?, 0.01); -} - // ── displayResults rendering ───────────────────────────────── test "displayResults: renders every row classification and the totals block" { @@ -1784,121 +557,6 @@ test "displayRatioSuggestions: cash/option/only rows produce no output" { // No qualifying (matched, non-cash, non-option) rows -> header never prints. try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } - -// ── Absent-account detection ───────────────────────────────── - -test "presentNumbers: collects account_number from each result" { - const allocator = std.testing.allocator; - - const results = [_]AccountComparison{ - .{ .account_name = "Sample IRA", .brokerage_name = "Fid", .account_number = "1234", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false }, - .{ .account_name = "Sample Brokerage", .brokerage_name = "Fid", .account_number = "5678", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false }, - }; - - const nums = try presentNumbers(allocator, AccountComparison, &results); - defer allocator.free(nums); - - try std.testing.expectEqual(@as(usize, 2), nums.len); - try std.testing.expectEqualStrings("1234", nums[0]); - try std.testing.expectEqualStrings("5678", nums[1]); -} - -test "findAbsentAccounts: flags held account missing from export; honors gating + closed-account suppression" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 19); - - var lots = [_]portfolio_mod.Lot{ - // fidelity #1234 -> held, absent from export -> SHOULD flag. - .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, - // fidelity #5678 -> held, present in export -> handled by main pass. - .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Sample Brokerage" }, - // fidelity #3456 -> only a closed lot, absent -> suppressed. - .{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 350.0, .account = "Sample Roth" }, - // schwab #9012 -> held, absent, but wrong institution for a Fidelity audit. - .{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" }, - .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "5678" }, - .{ .account = "Sample Roth", .tax_type = .roth, .institution = "fidelity", .account_number = "3456" }, - .{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" }, - // institution set but no account number -> can't match an export row -> skipped. - .{ .account = "Sample HSA", .tax_type = .hsa, .institution = "fidelity", .account_number = null }, - }; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - try prices.put("VTI", 210.0); - - // The Fidelity export contained only account #5678. - const present = [_][]const u8{"5678"}; - - const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "fidelity", &present, prices, as_of); - defer allocator.free(absent); - - try std.testing.expectEqual(@as(usize, 1), absent.len); - try std.testing.expectEqualStrings("Sample IRA", absent[0].account_name); - try std.testing.expectEqualStrings("1234", absent[0].account_number); - try std.testing.expectApproxEqAbs(@as(f64, 2100.0), absent[0].portfolio_total, 0.01); -} - -test "findAbsentAccounts: gating flags only the audited institution" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 19); - - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, - .{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" }, - .{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" }, - }; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - - // Auditing a Schwab export that matched no known account number: - // only the Schwab account surfaces; the Fidelity account is gated out. - const present = [_][]const u8{}; - const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "schwab", &present, prices, as_of); - defer allocator.free(absent); - - try std.testing.expectEqual(@as(usize, 1), absent.len); - try std.testing.expectEqualStrings("Schwab Trust", absent[0].account_name); - try std.testing.expectEqualStrings("9012", absent[0].account_number); -} - -test "findAbsentAccounts: no absent accounts when export covers every held account" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 19); - - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" }, - }; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - - const present = [_][]const u8{"1234"}; - const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "fidelity", &present, prices, as_of); - defer allocator.free(absent); - - try std.testing.expectEqual(@as(usize, 0), absent.len); -} - test "displayAbsentAccounts: silent when empty, renders names + totals otherwise" { var buf: [1024]u8 = undefined; diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index 0eb21c9..9499daa 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -26,7 +26,7 @@ const git = @import("../../git.zig"); const test_git = @import("../../testutil/git.zig"); const common = @import("common.zig"); -const fidelity = @import("fidelity.zig"); +const fidelity = @import("../../analytics/reconcile/fidelity.zig"); const schwab = @import("schwab.zig"); const fmt = cli.fmt; diff --git a/src/commands/audit/schwab.zig b/src/commands/audit/schwab.zig index e0a3027..c53504d 100644 --- a/src/commands/audit/schwab.zig +++ b/src/commands/audit/schwab.zig @@ -1,144 +1,22 @@ -//! Schwab reconcilers for the `audit` command. -//! -//! Schwab has two export shapes, so this module carries more than -//! the Fidelity one: -//! -//! 1. **Per-account positions CSV** (`--schwab`) - same per-account -//! positions shape Fidelity uses, so it feeds the shared -//! `common.compareAccounts` engine via `reconcileCsv`. -//! 2. **Account summary paste** (`--schwab-summary`) - a -//! per-account totals-only view with no per-symbol detail. This -//! is the one genuinely broker-specific reconciler, with its own -//! comparison type (`SchwabAccountComparison`), comparator -//! (`compareSchwabSummary`), and display. -//! -//! Parsing for both lives in `brokerage/schwab.zig`. +//! Display/rendering for the Schwab audit output. Reconciliation +//! compute lives in `analytics/reconcile/schwab.zig`; re-exported here +//! for back-compat so `audit.zig` / `hygiene.zig` keep using `schwab.*`. const std = @import("std"); const zfin = @import("../../root.zig"); const cli = @import("../common.zig"); const Money = @import("../../Money.zig"); const analysis = @import("../../analytics/analysis.zig"); +const portfolio_mod = @import("../../models/portfolio.zig"); const Date = @import("../../Date.zig"); const common = @import("common.zig"); -const schwab_parser = @import("../../brokerage/schwab.zig"); +const reconcile = @import("../../analytics/reconcile.zig"); -const AccountSummary = schwab_parser.AccountSummary; - -/// 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, - /// Account holds open options: zfin values them at cost, the broker - /// at market, so a totals-level value delta is expected (mute it). - has_options: bool = false, - /// Bounded band within which a value delta is an expected CD mark. - cd_allowance: f64 = 0, - has_discrepancy: bool, -}; - -// ── Per-account positions CSV (--schwab) ───────────────────── - -/// Parse a Schwab per-account positions CSV and reconcile it against -/// the portfolio via the shared engine. Returns owned -/// `AccountComparison` results (free each `.comparisons` slice, then -/// the results slice). String fields borrow from `csv_data`, which -/// must outlive them. Propagates parser and allocation errors. -pub fn reconcileCsv( - allocator: std.mem.Allocator, - portfolio: zfin.Portfolio, - csv_data: []const u8, - account_map: analysis.AccountMap, - prices: std.StringHashMap(f64), - as_of: Date, -) ![]common.AccountComparison { - const parsed = try schwab_parser.parseCsv(allocator, csv_data); - // Result strings borrow from `csv_data`, not the positions slice, - // so freeing the slice array here is safe. - defer allocator.free(parsed.positions); - return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of); -} - -// ── Account summary paste (--schwab-summary) ───────────────── - -/// Parse a Schwab account summary and reconcile its per-account -/// totals against portfolio.srf. Returns owned results (free the -/// slice). String fields borrow from `summary_data`, which must -/// outlive them. Propagates parser (`NoAccountsFound`) and -/// allocation errors. -pub fn reconcileSummary( - allocator: std.mem.Allocator, - portfolio: zfin.Portfolio, - summary_data: []const u8, - account_map: analysis.AccountMap, - prices: std.StringHashMap(f64), - as_of: Date, -) ![]SchwabAccountComparison { - const schwab_accounts = try schwab_parser.parseSummary(allocator, summary_data); - defer allocator.free(schwab_accounts); - return compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of); -} - -/// Compare Schwab summary against portfolio.srf account totals. -pub fn compareSchwabSummary( - allocator: std.mem.Allocator, - portfolio: zfin.Portfolio, - schwab_accounts: []const AccountSummary, - 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; - var expectation: common.AccountValueExpectation = .{}; - - if (portfolio_acct) |pa| { - pf_cash = portfolio.cashForAccount(pa); - pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices); - expectation = common.accountValueExpectation(portfolio, as_of, pa); - } - - 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) < common.cash_tolerance else true; - const total_ok = if (total_delta) |d| @abs(d) < common.value_tolerance 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_options = expectation.has_options, - .cd_allowance = expectation.cd_allowance, - // A value delta is a discrepancy regardless of cause: an - // audit must never hide it. Muting (below) is a display - // hint, not a suppression - so visibility stays keyed on the - // raw tolerance, same as before. - .has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null, - }); - } - - return results.toOwnedSlice(allocator); -} +pub const SchwabAccountComparison = reconcile.SchwabAccountComparison; +pub const compareSchwabSummary = reconcile.compareSchwabSummary; +pub const reconcileCsv = reconcile.reconcileCsv; +pub const reconcileSummary = reconcile.reconcileSummary; +pub const hasSchwabDiscrepancies = reconcile.hasSchwabDiscrepancies; pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void { try cli.printBold(out, color, "\nSchwab Account Audit", .{}); @@ -346,422 +224,6 @@ pub fn displaySchwabSummaryRatioSuggestions( if (has_header) try out.print("\n", .{}); } - -/// Check if any Schwab summary results have discrepancies. -pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool { - for (results) |r| { - if (r.has_discrepancy) return true; - } - return false; -} - -// ── Tests ──────────────────────────────────────────────────── - -const portfolio_mod = @import("../../models/portfolio.zig"); - -test "hasSchwabDiscrepancies" { - const clean = [_]SchwabAccountComparison{.{ - .account_name = "IRA", - .schwab_name = "Roth IRA", - .account_number = "1234", - .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 = "1234", - .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 "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 = "Sample Brokerage", - }, - .{ - .symbol = "AAPL", - .shares = 10, - .open_date = Date.fromYmd(2024, 1, 1), - .open_price = 150, - .account = "Sample Brokerage", - }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; - - const schwab_accounts = [_]AccountSummary{ - .{ - .account_name = "Sample Brokerage", - .account_number = "1234", - .cash = 5000.0, - .total_value = 7000.0, - }, - }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ - .account = "Sample 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("Sample 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 = [_]AccountSummary{ - .{ - .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: sub-dollar cash drift is flagged (cash matches to the penny)" { - const allocator = std.testing.allocator; - const today = Date.fromYmd(2026, 6, 19); - - // Portfolio cash $38.75; Schwab reports $38.97 - a $0.22 accrual. - // Below the $1 securities tolerance, but a real cash drift that - // must surface. - const lots = [_]portfolio_mod.Lot{ - .{ .symbol = "CASH", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; - - const schwab_accounts = [_]AccountSummary{ - .{ .account_name = "Sample Brokerage", .account_number = "1234", .cash = 38.97, .total_value = 38.97 }, - }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample 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, 0.22), results[0].cash_delta.?, 0.001); - 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 = [_]AccountSummary{ - .{ - .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 = [_]AccountSummary{ - .{ - .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 = [_]AccountSummary{ - .{ - .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); - } -} - -// ── reconcile wrappers (parse + compare wiring) ────────────── - -test "reconcileCsv: parses a Schwab positions CSV and reconciles it" { - const allocator = std.testing.allocator; - const csv = - "\"Positions for account Sample Trust ...1234 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"; - - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "AMZN", .shares = 1488, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 74, .account = "Sample Trust" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample Trust", .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("AMZN", 239.208); - - const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 4, 10)); - defer { - for (results) |r| allocator.free(r.comparisons); - allocator.free(results); - } - - try std.testing.expectEqual(@as(usize, 1), results.len); - try std.testing.expectEqualStrings("Sample Trust", results[0].account_name); - var found_amzn = false; - for (results[0].comparisons) |c| { - if (std.mem.eql(u8, c.symbol, "AMZN")) found_amzn = true; - } - try std.testing.expect(found_amzn); -} - -test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" { - const allocator = std.testing.allocator; - const data = - \\Sample Roth - \\Account number ending in 1234 ...1234 - \\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47% - \\Sample Inherited IRA - \\Account number ending in 5678 ...5678 - \\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73% - ; - const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator }; - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample Roth 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 reconcileSummary(allocator, portfolio, data, acct_map, prices, Date.fromYmd(2026, 4, 10)); - defer allocator.free(results); - - try std.testing.expectEqual(@as(usize, 2), results.len); - // 1234 maps; 5678 is absent from the map -> unmapped (empty name). - try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name); - try std.testing.expectEqualStrings("", results[1].account_name); -} - -test "compareSchwabSummary: populates option flag and CD allowance from the portfolio" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 28); - var lots = [_]portfolio_mod.Lot{ - // CD >1yr out -> caps at one year's coupon: 4% of 50000 = 2000. - .{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" }, - // Open option -> has_options. - .{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - var entries = [_]analysis.AccountTaxEntry{ - .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" }, - }; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - var summary = [_]AccountSummary{ - .{ .account_name = "IRA", .account_number = "1234", .cash = 0, .total_value = 60000 }, - }; - - const results = try compareSchwabSummary(allocator, portfolio, &summary, acct_map, prices, as_of); - defer allocator.free(results); - - try std.testing.expectEqual(@as(usize, 1), results.len); - try std.testing.expect(results[0].has_options); - try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].cd_allowance, 0.01); -} - // ── displaySchwabResults rendering ─────────────────────────── test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {