From be888069c0c325432f6b76de1970d54b408086e1 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 24 Jun 2026 15:50:27 -0700 Subject: [PATCH] split audit by broker --- TODO.md | 43 - src/brokerage/fidelity.zig | 137 +- src/commands/audit.zig | 3448 +------------------------------ src/commands/audit/common.zig | 1672 +++++++++++++++ src/commands/audit/fidelity.zig | 91 + src/commands/audit/hygiene.zig | 1536 ++++++++++++++ src/commands/audit/schwab.zig | 832 ++++++++ src/models/option.zig | 200 ++ src/models/portfolio.zig | 6 +- 9 files changed, 4397 insertions(+), 3568 deletions(-) create mode 100644 src/commands/audit/common.zig create mode 100644 src/commands/audit/fidelity.zig create mode 100644 src/commands/audit/hygiene.zig create mode 100644 src/commands/audit/schwab.zig diff --git a/TODO.md b/TODO.md index a1ae4b2..2111d0a 100644 --- a/TODO.md +++ b/TODO.md @@ -160,49 +160,6 @@ settles on. Starting points (grep `\.note` and `note::`): - `transaction_log` transfer `note` (annotation). - audit / contributions matchers (do any key off notes?). -## Split `audit.zig` into per-broker reconcilers — priority LOW - -`src/commands/audit.zig` is now 2856 lines (was 3438) after the -brokerage parsers moved to per-broker files under `src/brokerage/`. -It still bundles three logically distinct responsibilities: - -- Portfolio hygiene check (no-flag mode) -- Fidelity positions CSV reconciler (`--fidelity`) -- Schwab per-account positions CSV reconciler (`--schwab`) and - Schwab account-summary stdin reconciler (`--schwab-summary`) - -The brokerage parsers themselves are split per broker: -`src/brokerage/types.zig` (shared `BrokeragePosition` + -`parseDollarAmount`), `src/brokerage/fidelity.zig` (Fidelity CSV + -option-symbol matcher), `src/brokerage/schwab.zig` (per-account -CSV + summary paste). Adding a new broker is a one-file add next -to those. What's left is splitting the *reconciler* -(compare-portfolio-vs-brokerage) and *display* code in audit.zig -into per-broker files that consume those parsers. - -### Sketch - -``` -src/commands/audit/ - mod.zig ← thin dispatcher; current public `run()` lives here - hygiene.zig ← portfolio hygiene check (no-flag mode) - fidelity.zig ← --fidelity reconciler (uses brokerage/fidelity.zig) - schwab.zig ← --schwab + --schwab-summary reconcilers - common.zig ← shared types (Discrepancy, ReconcileResult), formatters -``` - -The hygiene check can be referenced from `zfin doctor` (above) -without pulling in reconciler baggage. - -### Driver - -Maintenance friction. The split makes the audit-bug investigations -already in this TODO file (phantom discrepancy on freshly-added -lots) easier to localize, and lets a `zfin doctor` command reuse -hygiene without inheriting the reconciliation surface. - -Pure internal refactor; no user-visible change. - ## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers — priority LOW `src/format.zig` is still a ~1700-line grab-bag, but the money- and diff --git a/src/brokerage/fidelity.zig b/src/brokerage/fidelity.zig index f458673..0e3f3b5 100644 --- a/src/brokerage/fidelity.zig +++ b/src/brokerage/fidelity.zig @@ -1,8 +1,9 @@ //! Fidelity export parsers. //! -//! Parses the CSV produced by Fidelity's "Download Positions" feature, -//! plus the structured option-symbol matching used to tie a Fidelity -//! option row back to a portfolio lot. +//! Parses the CSV produced by Fidelity's "Download Positions" feature. +//! (Compact option-symbol matching against a portfolio lot now lives +//! in `models/option.zig` as `symbolMatchesLot`, since the audit +//! reconciler applies it across brokers, not just Fidelity.) //! //! ## Limitations of this CSV parser //! @@ -37,7 +38,6 @@ //! RFC 4180 fully (quoted fields, escaping, multi-line values). const std = @import("std"); -const Date = @import("../Date.zig"); const portfolio_mod = @import("../models/portfolio.zig"); const types = @import("types.zig"); @@ -142,69 +142,6 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosi return positions.toOwnedSlice(allocator); } -/// Check if a Fidelity option symbol (e.g. "-AMZN260515C220") matches a -/// portfolio lot by comparing parsed components against the lot's structured -/// fields (underlying, maturity_date, option_type, strike). -/// -/// Fidelity format: [-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE} -/// The underlying length is variable, so we scan for the first position -/// where 6 consecutive digits encode a valid date. -pub fn optionMatchesLot(symbol: []const u8, lot: portfolio_mod.Lot) bool { - if (lot.security_type != .option) return false; - - // Strip leading dash (short indicator) - const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol; - - // Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9 - if (sym.len < 9) return false; - - // Scan for the date boundary: first position where 6 consecutive digits - // form a valid YYMMDD (and the character before is a letter). - var i: usize = 1; // underlying is at least 1 char - while (i + 7 < sym.len) : (i += 1) { - // All 6 chars must be digits - if (!std.ascii.isDigit(sym[i]) or - !std.ascii.isDigit(sym[i + 1]) or - !std.ascii.isDigit(sym[i + 2]) or - !std.ascii.isDigit(sym[i + 3]) or - !std.ascii.isDigit(sym[i + 4]) or - !std.ascii.isDigit(sym[i + 5])) - continue; - - // Character after the 6 digits must be C or P - const type_char = sym[i + 6]; - if (type_char != 'C' and type_char != 'P') continue; - - // Parse date components - const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue; - const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue; - const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue; - if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue; - const year = 2000 + yy; - - // Parse components - const underlying = sym[0..i]; - const option_type: portfolio_mod.OptionType = if (type_char == 'P') .put else .call; - const strike_str = sym[i + 7 ..]; - const strike = std.fmt.parseFloat(f64, strike_str) catch continue; - const date = Date.fromYmd(year, mm, dd); - - // Match against lot fields - const lot_underlying = lot.underlying orelse return false; - const lot_maturity = lot.maturity_date orelse return false; - - if (!std.mem.eql(u8, underlying, lot_underlying)) return false; - if (!lot_maturity.eql(date)) return false; - if (option_type != lot.option_type) return false; - if (lot.strike) |ls| { - if (@abs(ls - strike) > 0.01) return false; - } else return false; - - return true; - } - return false; -} - // ── Tests ──────────────────────────────────────────────────── test "parseCsv basic" { @@ -310,69 +247,3 @@ test "parseCsv cash account type is not cash position" { try std.testing.expect(!positions[0].is_cash); try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01); } - -test "optionMatchesLot basic call" { - const lot = portfolio_mod.Lot{ - .symbol = "AMZN 05/15/2026 220.00 C", - .security_type = .option, - .underlying = "AMZN", - .strike = 220.0, - .option_type = .call, - .maturity_date = Date.fromYmd(2026, 5, 15), - .shares = -3, - .open_date = Date.fromYmd(2025, 1, 1), - .open_price = 8.75, - }; - - // Fidelity format with leading dash (short) - try std.testing.expect(optionMatchesLot("-AMZN260515C220", lot)); - // Without dash - try std.testing.expect(optionMatchesLot("AMZN260515C220", lot)); - // Wrong underlying - try std.testing.expect(!optionMatchesLot("-MSFT260515C220", lot)); - // Wrong date - try std.testing.expect(!optionMatchesLot("-AMZN260615C220", lot)); - // Wrong type - try std.testing.expect(!optionMatchesLot("-AMZN260515P220", lot)); - // Wrong strike - try std.testing.expect(!optionMatchesLot("-AMZN260515C230", lot)); - // Non-option lot - const stock_lot = portfolio_mod.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 }; - try std.testing.expect(!optionMatchesLot("-AMZN260515C220", stock_lot)); -} - -test "optionMatchesLot put option and decimal strike" { - const lot = portfolio_mod.Lot{ - .symbol = "AAPL 06/20/2026 220.50 P", - .security_type = .option, - .underlying = "AAPL", - .strike = 220.50, - .option_type = .put, - .maturity_date = Date.fromYmd(2026, 6, 20), - .shares = -1, - .open_date = Date.fromYmd(2025, 1, 1), - .open_price = 5.0, - }; - - try std.testing.expect(optionMatchesLot("-AAPL260620P220.50", lot)); - try std.testing.expect(optionMatchesLot("AAPL260620P220.50", lot)); - // Call doesn't match put - try std.testing.expect(!optionMatchesLot("-AAPL260620C220.50", lot)); -} - -test "optionMatchesLot single-char underlying" { - const lot = portfolio_mod.Lot{ - .symbol = "A 03/20/2026 150.00 C", - .security_type = .option, - .underlying = "A", - .strike = 150.0, - .option_type = .call, - .maturity_date = Date.fromYmd(2026, 3, 20), - .shares = -2, - .open_date = Date.fromYmd(2025, 1, 1), - .open_price = 3.0, - }; - - try std.testing.expect(optionMatchesLot("-A260320C150", lot)); - try std.testing.expect(!optionMatchesLot("-A260320P150", lot)); -} diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 80dd3fa..c50cc38 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -1,2095 +1,30 @@ +//! `zfin audit` command dispatcher. +//! +//! Thin entry point: argument parsing plus routing to one of the +//! per-responsibility modules in the `audit/` directory: +//! +//! - `audit/hygiene.zig` — flagless portfolio hygiene check (no flags) +//! - `audit/fidelity.zig` — `--fidelity` positions-CSV reconciler +//! - `audit/schwab.zig` — `--schwab` positions-CSV + `--schwab-summary` +//! reconcilers +//! - `audit/common.zig` — shared comparison types + per-account display +//! +//! This file sits beside its `audit/` directory (the `tui.zig` + +//! `tui/` convention), not as a `mod.zig` inside it. +//! +//! The split keeps each broker's reconcile/display logic in its own +//! file so adding a broker is a one-file add next to the others, and +//! so a future `zfin doctor` can reuse the hygiene check without +//! pulling in the reconciliation surface. + const std = @import("std"); -const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); -const Money = @import("../Money.zig"); -const analysis = @import("../analytics/analysis.zig"); -const portfolio_mod = @import("../models/portfolio.zig"); -const contributions = @import("contributions.zig"); -const brokerage_types = @import("../brokerage/types.zig"); -const fidelity = @import("../brokerage/fidelity.zig"); -const schwab = @import("../brokerage/schwab.zig"); -const Date = @import("../Date.zig"); - -// Local aliases used inside this file. The brokerage parsers and -// their record types live under `src/brokerage/`; this file is the -// reconciler that compares those records against the portfolio. -const BrokeragePosition = brokerage_types.BrokeragePosition; -const SchwabAccountSummary = schwab.AccountSummary; -const parseFidelityCsv = fidelity.parseCsv; -const parseSchwabCsv = schwab.parseCsv; -const parseSchwabSummary = schwab.parseSummary; -const fidelityOptionMatchesLot = fidelity.optionMatchesLot; - -/// 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). -const value_tolerance: f64 = 1.0; -const cash_tolerance: f64 = 0.01; - -/// Account-level comparison result for Schwab summary audit. -pub const SchwabAccountComparison = struct { - account_name: []const u8, - schwab_name: []const u8, - account_number: []const u8, - portfolio_cash: f64, - schwab_cash: ?f64, - cash_delta: ?f64, - portfolio_total: f64, - schwab_total: ?f64, - total_delta: ?f64, - has_discrepancy: bool, -}; - -/// Resolved position value for audit display: effective per-share price -/// and total market value, with correct `price_ratio` handling based on -/// the price's provenance. -/// -/// Two sources feed `prices`: -/// 1. Live candle close — NOT preadjusted for the lot's share class, -/// so `price_ratio` must be applied. -/// 2. `pos.avg_cost` fallback — already in the lot's share-class -/// terms (user paid institutional-class prices to open the lot), -/// so `price_ratio` must be skipped. -/// -/// See the "Pricing model" block in `models/portfolio.zig` for the full -/// treatment. This helper is the audit-side mirror of the snapshot -/// side's `buildFallbackPrices` + `manual_set` pair. -const ResolvedValue = struct { price: f64, value: f64 }; - -fn resolvePositionValue(pos: zfin.Position, prices: std.StringHashMap(f64)) ResolvedValue { - if (prices.get(pos.symbol)) |live| { - return .{ - .price = pos.effectivePrice(live, false), - .value = pos.marketValue(live, false), - }; - } - // Fallback: avg_cost. Already preadjusted. - return .{ - .price = pos.effectivePrice(pos.avg_cost, true), - .value = pos.marketValue(pos.avg_cost, true), - }; -} - -/// Compare Schwab summary against portfolio.srf account totals. -pub fn compareSchwabSummary( - allocator: std.mem.Allocator, - portfolio: zfin.Portfolio, - schwab_accounts: []const SchwabAccountSummary, - account_map: analysis.AccountMap, - prices: std.StringHashMap(f64), - as_of: Date, -) ![]SchwabAccountComparison { - var results = std.ArrayList(SchwabAccountComparison).empty; - errdefer results.deinit(allocator); - - for (schwab_accounts) |sa| { - const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number); - - var pf_cash: f64 = 0; - var pf_total: f64 = 0; - - if (portfolio_acct) |pa| { - pf_cash = portfolio.cashForAccount(pa); - pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices); - } - - const cash_delta = if (sa.cash) |sc| sc - pf_cash else null; - const total_delta = if (sa.total_value) |st| st - pf_total else null; - - const cash_ok = if (cash_delta) |d| @abs(d) < cash_tolerance else true; - const total_ok = if (total_delta) |d| @abs(d) < 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_discrepancy = !cash_ok or !total_ok or portfolio_acct == null, - }); - } - - return results.toOwnedSlice(allocator); -} - -fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void { - try cli.printBold(out, color, "\nSchwab Account Audit", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{}); - try out.print("========================================\n\n", .{}); - - // Column headers - try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>14} {s:>14} {s:>14} {s:>14}\n", .{ - "Account", "PF Cash", "BR Cash", "PF Total", "BR Total", - }); - - var grand_pf: f64 = 0; - var grand_br: f64 = 0; - var discrepancy_count: usize = 0; - - for (results) |r| { - const label = if (r.account_name.len > 0) r.account_name else r.schwab_name; - - var br_cash_buf: [24]u8 = undefined; - var br_total_buf: [24]u8 = undefined; - - const br_cash_str = if (r.schwab_cash) |c| - std.fmt.bufPrint(&br_cash_buf, "{f}", .{Money.from(c)}) catch "$?" - else - "--"; - const br_total_str = if (r.schwab_total) |t| - std.fmt.bufPrint(&br_total_buf, "{f}", .{Money.from(t)}) catch "$?" - else - "--"; - - const cash_ok = if (r.cash_delta) |d| @abs(d) < cash_tolerance else true; - const total_ok = if (r.total_delta) |d| @abs(d) < value_tolerance else true; - const is_unmapped = r.account_name.len == 0; - const is_real_mismatch = !cash_ok or is_unmapped; - - if (is_real_mismatch) discrepancy_count += 1; - - // Account label - try out.print(" ", .{}); - if (is_unmapped) { - try cli.printFg(out, color, cli.CLR_WARNING, "{s:<24}", .{label}); - } else { - try out.print("{s:<24}", .{label}); - } - - // PF Cash — colored if mismatched (brokerage is truth) - try out.print(" ", .{}); - if (!cash_ok) { - const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; - try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_cash).padRight(14)}); - } else { - try out.print("{f}", .{Money.from(r.portfolio_cash).padRight(14)}); - } - - // BR Cash - try out.print(" {s:>14}", .{br_cash_str}); - - // PF Total — colored if not just stale prices - try out.print(" ", .{}); - if (!total_ok and !cash_ok) { - const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; - try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)}); - } else { - try out.print("{f}", .{Money.from(r.portfolio_total).padRight(14)}); - } - - // BR Total - try out.print(" {s:>14}", .{br_total_str}); - - // Status - if (is_unmapped) { - try cli.printFg(out, color, cli.CLR_WARNING, " Unmapped", .{}); - } else if (!cash_ok) { - const d = r.cash_delta.?; - const sign: []const u8 = if (d >= 0) "+" else "-"; - try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) }); - } else if (!total_ok) { - const d = r.total_delta.?; - const sign: []const u8 = if (d >= 0) "+" else "-"; - try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) }); - } - try out.print("\n", .{}); - - grand_pf += r.portfolio_total; - if (r.schwab_total) |t| grand_br += t; - } - - // Grand totals - try out.print("\n", .{}); - const grand_delta = grand_br - grand_pf; - - try cli.printBold(out, color, " Total: portfolio {f} schwab {f}", .{ - Money.from(grand_pf), - Money.from(grand_br), - }); - - if (@abs(grand_delta) < 1.0) { - // no delta - } else { - const sign: []const u8 = if (grand_delta >= 0) "+" else "-"; - const rgb = if (grand_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; - try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_delta)) }); - } - try out.print("\n", .{}); - - if (discrepancy_count > 0) { - try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} — drill down with: zfin audit --schwab \n", .{ - discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"), - }); - } - try out.print("\n", .{}); -} - -// ── Audit logic ───────────────────────────────────────────── - -/// Comparison result for a single symbol within an account. -pub const SymbolComparison = struct { - symbol: []const u8, - portfolio_shares: f64, - brokerage_shares: ?f64, - portfolio_price: ?f64, - brokerage_price: ?f64, - portfolio_value: f64, - brokerage_value: ?f64, - shares_delta: ?f64, - value_delta: ?f64, - is_cash: bool, - is_option: bool, - only_in_brokerage: bool, - only_in_portfolio: bool, -}; - -/// Comparison result for a single account. -pub const AccountComparison = struct { - account_name: []const u8, - brokerage_name: []const u8, - account_number: []const u8, - comparisons: []const SymbolComparison, - portfolio_total: f64, - brokerage_total: f64, - total_delta: f64, - option_value_delta: f64, - has_discrepancies: bool, -}; - -/// 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, -}; - -/// Consolidate broker rows that share a symbol within the same -/// account into a single position. Some brokers split a single -/// stock holding into separate "Cash" and "Margin" rows for the -/// same ticker in the same account — Fidelity does this when a -/// freshly-credited lot (e.g. an RSU distribution) hasn't yet -/// cleared settlement (T+1 / T+2) and is therefore considered -/// un-marginable, while the older settled shares stay in the -/// margin sub-account. Without consolidation, the audit would -/// double-count when matching against the portfolio's -/// account-level aggregate. -/// -/// Aggregation rules: -/// - `quantity` and `current_value` are summed across rows -/// (treating null as 0 for the sum, but preserving null when -/// no row supplied a value). -/// - `cost_basis` is summed the same way. -/// - `is_cash` is OR-ed across rows: any cash row in the group -/// marks the consolidated entry as cash. In practice a single -/// symbol is either always-cash (money market) or never (stock), -/// so this is just defensive. -/// - `account_number`, `account_name`, `description` are taken -/// from the first row in the group. -/// -/// Caller owns the returned ArrayList. -fn consolidateBySymbol( - allocator: std.mem.Allocator, - rows: []const BrokeragePosition, -) !std.ArrayList(BrokeragePosition) { - var by_symbol = std.StringHashMap(usize).init(allocator); - defer by_symbol.deinit(); - - var out: std.ArrayList(BrokeragePosition) = .empty; - errdefer out.deinit(allocator); - - for (rows) |bp| { - if (by_symbol.get(bp.symbol)) |idx| { - const existing = &out.items[idx]; - // Sum quantity (null + value = value; null + null = null). - existing.quantity = sumOptional(existing.quantity, bp.quantity); - existing.current_value = sumOptional(existing.current_value, bp.current_value); - existing.cost_basis = sumOptional(existing.cost_basis, bp.cost_basis); - existing.is_cash = existing.is_cash or bp.is_cash; - } else { - try by_symbol.put(bp.symbol, out.items.len); - try out.append(allocator, bp); - } - } - - return out; -} - -fn sumOptional(a: ?f64, b: ?f64) ?f64 { - if (a == null and b == null) return null; - return (a orelse 0) + (b orelse 0); -} - -/// Build per-account comparisons between portfolio.srf and brokerage data. -pub fn compareAccounts( - allocator: std.mem.Allocator, - portfolio: zfin.Portfolio, - brokerage_positions: []const BrokeragePosition, - account_map: analysis.AccountMap, - institution: []const u8, - prices: std.StringHashMap(f64), - as_of: Date, -) ![]AccountComparison { - var results = std.ArrayList(AccountComparison).empty; - errdefer results.deinit(allocator); - - // Group brokerage positions by account number - var brokerage_accounts = std.StringHashMap(std.ArrayList(BrokeragePosition)).init(allocator); - defer { - var it = brokerage_accounts.valueIterator(); - while (it.next()) |v| v.deinit(allocator); - brokerage_accounts.deinit(); - } - - for (brokerage_positions) |bp| { - const entry = try brokerage_accounts.getOrPut(bp.account_number); - if (!entry.found_existing) { - entry.value_ptr.* = .empty; - } - try entry.value_ptr.append(allocator, bp); - } - - // Aggregate same-symbol rows within each account. Some brokers - // report a single security as multiple rows when a position - // straddles sub-account contexts. The motivating case is - // Fidelity's margin-eligible accounts: when a freshly-credited - // lot (e.g. an RSU distribution) hasn't yet cleared settlement - // (T+1 / T+2), Fidelity classifies the new shares as - // un-marginable "Cash" and the older settled shares as - // "Margin", reporting them as two CSV rows for the same - // ticker in the same account number. Once settlement clears, - // the rows usually consolidate back into one — but until - // then, the audit needs to consolidate them itself, otherwise - // it'd match each broker row independently against the - // (already-aggregated) portfolio total and report a phantom - // discrepancy on every duplicate. Aggregating here lets the - // rest of the comparator stay (account, symbol)-keyed - // regardless of how the broker chose to slice the rows. - var consolidated_accounts = std.StringHashMap(std.ArrayList(BrokeragePosition)).init(allocator); - defer { - var it = consolidated_accounts.valueIterator(); - while (it.next()) |v| v.deinit(allocator); - consolidated_accounts.deinit(); - } - { - var acct_it = brokerage_accounts.iterator(); - while (acct_it.next()) |kv| { - const consolidated = try consolidateBySymbol(allocator, kv.value_ptr.items); - try consolidated_accounts.put(kv.key_ptr.*, consolidated); - } - } - - // For each brokerage account, find the matching portfolio account and compare - var acct_iter = consolidated_accounts.iterator(); - while (acct_iter.next()) |kv| { - const acct_num = kv.key_ptr.*; - const broker_positions = kv.value_ptr.items; - if (broker_positions.len == 0) continue; - - const broker_name = broker_positions[0].account_name; - const portfolio_acct_name = account_map.findByInstitutionAccount(institution, acct_num); - - var comparisons = std.ArrayList(SymbolComparison).empty; - errdefer comparisons.deinit(allocator); - - var portfolio_total: f64 = 0; - var brokerage_total: f64 = 0; - var option_value_delta: f64 = 0; - var has_discrepancies = false; - - // Track which portfolio symbols we've matched - var matched_symbols = std.StringHashMap(void).init(allocator); - defer matched_symbols.deinit(); - - // Compare each brokerage position against portfolio - for (broker_positions) |bp| { - const bp_value = bp.current_value orelse 0; - brokerage_total += bp_value; - - if (portfolio_acct_name == null) { - const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null; - try comparisons.append(allocator, .{ - .symbol = bp.symbol, - .portfolio_shares = 0, - .brokerage_shares = bp.quantity, - .portfolio_price = null, - .brokerage_price = br_price, - .portfolio_value = 0, - .brokerage_value = bp.current_value, - .shares_delta = if (bp.quantity) |q| q else null, - .value_delta = bp.current_value, - .is_cash = bp.is_cash, - .is_option = false, - .only_in_brokerage = true, - .only_in_portfolio = false, - }); - has_discrepancies = true; - continue; - } - - // Sum portfolio lots for this symbol+account - var pf_shares: f64 = 0; - var pf_value: f64 = 0; - var pf_price: ?f64 = null; - var is_option = false; - - if (bp.is_cash) { - pf_shares = portfolio.cashForAccount(portfolio_acct_name.?); - pf_value = pf_shares; - } else { - const acct_positions = portfolio.positionsForAccount(as_of, allocator, portfolio_acct_name.?) catch &.{}; - defer allocator.free(acct_positions); - - var found_stock = false; - for (acct_positions) |pos| { - if (!std.mem.eql(u8, pos.symbol, bp.symbol) and - !std.mem.eql(u8, pos.lot_symbol, bp.symbol)) - continue; - pf_shares = pos.shares; - const v = resolvePositionValue(pos, prices); - pf_price = v.price; - pf_value = v.value; - try matched_symbols.put(pos.symbol, {}); - try matched_symbols.put(pos.lot_symbol, {}); - found_stock = true; - } - - if (!found_stock) { - for (portfolio.lots) |lot| { - const lot_acct = lot.account orelse continue; - if (!std.mem.eql(u8, lot_acct, portfolio_acct_name.?)) continue; - if (!lot.isOpen(as_of)) continue; - // Match by exact symbol, or by parsed option components - // (Fidelity uses compact OCC format like "-AMZN260515C220" - // while portfolio uses "AMZN 05/15/2026 220.00 C") - if (!std.mem.eql(u8, lot.symbol, bp.symbol) and - !fidelityOptionMatchesLot(bp.symbol, lot)) continue; - switch (lot.security_type) { - .cd => { - pf_shares += lot.shares; - pf_value += lot.shares; - pf_price = 1.0; - }, - .option => { - pf_shares += lot.shares; - pf_value += @abs(lot.shares) * lot.open_price * lot.multiplier; - pf_price = lot.open_price * lot.multiplier; - is_option = true; - }, - else => {}, - } - // Track the lot's own symbol so the portfolio-only pass skips it - try matched_symbols.put(lot.symbol, {}); - } - if (pf_shares != 0) try matched_symbols.put(bp.symbol, {}); - } - } - - try matched_symbols.put(bp.symbol, {}); - portfolio_total += pf_value; - - const shares_delta = if (bp.quantity) |bq| bq - pf_shares else null; - const value_delta = if (bp.current_value) |bv| bv - pf_value else null; - - const shares_match = if (shares_delta) |d| @abs(d) < 0.01 else true; - // 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, - .only_in_brokerage = pf_shares == 0 and pf_value == 0, - .only_in_portfolio = false, - }); - } - - // Find portfolio-only positions (in portfolio but not in brokerage) - if (portfolio_acct_name) |pa| { - const acct_positions = portfolio.positionsForAccount(as_of, allocator, pa) catch &.{}; - defer allocator.free(acct_positions); - - for (acct_positions) |pos| { - if (matched_symbols.contains(pos.symbol)) continue; - if (matched_symbols.contains(pos.lot_symbol)) continue; - - try matched_symbols.put(pos.symbol, {}); - - const v = resolvePositionValue(pos, prices); - const mv = v.value; - portfolio_total += mv; - - has_discrepancies = true; - try comparisons.append(allocator, .{ - .symbol = pos.symbol, - .portfolio_shares = pos.shares, - .brokerage_shares = null, - .portfolio_price = v.price, - .brokerage_price = null, - .portfolio_value = mv, - .brokerage_value = null, - .shares_delta = null, - .value_delta = null, - .is_cash = false, - .is_option = false, - .only_in_brokerage = false, - .only_in_portfolio = true, - }); - } - - // Portfolio-only CDs and options - for (portfolio.lots) |lot| { - const lot_acct = lot.account orelse continue; - if (!std.mem.eql(u8, lot_acct, pa)) continue; - if (!lot.isOpen(as_of)) continue; - if (lot.security_type != .cd and lot.security_type != .option) continue; - if (matched_symbols.contains(lot.symbol)) continue; - - try matched_symbols.put(lot.symbol, {}); - - var pf_shares: f64 = 0; - var pf_value: f64 = 0; - var pf_price: ?f64 = null; - var is_cd = false; - - // Aggregate all lots with same symbol in this account - for (portfolio.lots) |lot2| { - const la2 = lot2.account orelse continue; - if (!std.mem.eql(u8, la2, pa)) continue; - if (!lot2.isOpen(as_of)) continue; - if (!std.mem.eql(u8, lot2.symbol, lot.symbol)) continue; - switch (lot2.security_type) { - .cd => { - pf_shares += lot2.shares; - pf_value += lot2.shares; - pf_price = 1.0; - is_cd = true; - }, - .option => { - pf_shares += lot2.shares; - pf_value += @abs(lot2.shares) * lot2.open_price * lot2.multiplier; - pf_price = lot2.open_price * lot2.multiplier; - }, - else => {}, - } - } - - if (pf_value != 0 or pf_shares != 0) { - portfolio_total += pf_value; - has_discrepancies = true; - try comparisons.append(allocator, .{ - .symbol = lot.symbol, - .portfolio_shares = pf_shares, - .brokerage_shares = null, - .portfolio_price = pf_price, - .brokerage_price = null, - .portfolio_value = pf_value, - .brokerage_value = null, - .shares_delta = null, - .value_delta = null, - .is_cash = is_cd, - .is_option = !is_cd, - .only_in_brokerage = false, - .only_in_portfolio = true, - }); - } - } - } - - try results.append(allocator, .{ - .account_name = portfolio_acct_name orelse "", - .brokerage_name = broker_name, - .account_number = acct_num, - .comparisons = try comparisons.toOwnedSlice(allocator), - .portfolio_total = portfolio_total, - .brokerage_total = brokerage_total, - .total_delta = brokerage_total - portfolio_total, - .option_value_delta = option_value_delta, - .has_discrepancies = has_discrepancies, - }); - } - - return results.toOwnedSlice(allocator); -} - -// ── Portfolio accounts absent from the export ──────────────── - -/// 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`. -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); -} - -// ── Ratio suggestions ──────────────────────────────────────── - -/// After displaying audit results, check for price_ratio positions where -/// the brokerage NAV implies a different ratio than what's configured. -/// Outputs actionable suggestions for portfolio.srf updates. -/// -/// Normally only lots with `price_ratio != 1.0` get suggestions — -/// the typical case is an institutional share class where the -/// configured ratio needs to drift toward current retail-vs- -/// institutional NAV. Lots with `price_ratio == 1.0` usually have -/// nothing to adjust. -/// -/// Exception: accounts flagged `direct_indexing::true` in -/// `accounts.srf`. These are proxy baskets whose tracking-error -/// drift is expressed by periodically nudging the ratio even -/// though the starting ratio is 1.0. For those accounts we still -/// emit a suggestion when brokerage and portfolio values disagree -/// — the suggested ratio is just `brokerage_NAV / retail_price` -/// applied against the existing lot share count, same formula as -/// the institutional-class case. -fn displayRatioSuggestions( - results: []const AccountComparison, - portfolio: zfin.Portfolio, - prices: std.StringHashMap(f64), - account_map: ?analysis.AccountMap, - color: bool, - out: *std.Io.Writer, -) !void { - var has_header = false; - - for (results) |acct| { - for (acct.comparisons) |cmp| { - // Skip unmatched, cash, and option rows - if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue; - if (cmp.is_cash or cmp.is_option) continue; - - // Is this account flagged direct-indexing? Captured once - // per outer loop so the per-lot gate can skip the - // ratio == 1.0 check for flagged accounts. - const is_direct_indexing = if (account_map) |am| - am.isDirectIndexing(acct.account_name) - else - false; - - // Find the portfolio lot(s) for this symbol with price_ratio != 1.0 - // (or any ratio, for direct-indexing accounts). - for (portfolio.lots) |lot| { - if (lot.price_ratio == 1.0 and !is_direct_indexing) continue; - if (lot.security_type != .stock) continue; - const lot_acct = lot.account orelse continue; - if (!std.mem.eql(u8, lot_acct, acct.account_name)) continue; - - // Match by lot_symbol (CUSIP) or ticker against brokerage symbol - const lot_sym = lot.symbol; - const price_sym = lot.priceSymbol(); - if (!std.mem.eql(u8, lot_sym, cmp.symbol) and - !std.mem.eql(u8, price_sym, cmp.symbol)) continue; - - // Get the retail price from cache - const retail_price = prices.get(price_sym) orelse continue; - // Brokerage price is the institutional NAV per share - const inst_nav = cmp.brokerage_price orelse continue; - if (retail_price == 0) continue; - - const current_ratio = lot.price_ratio; - const suggested_ratio = inst_nav / retail_price; - const drift_pct = (suggested_ratio - current_ratio) / current_ratio * 100.0; - - // Only suggest if drift is meaningful (> 0.01%) - if (current_ratio == suggested_ratio) break; - - if (!has_header) { - try out.print("\n", .{}); - try cli.printBold(out, color, " Ratio updates", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf)\n", .{}); - has_header = true; - } - - var cur_buf: [24]u8 = undefined; - var sug_buf: [24]u8 = undefined; - var drift_buf: [16]u8 = undefined; - const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{current_ratio}) catch "?"; - const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?"; - const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.2}%", .{drift_pct}) catch "?"; - - try out.print(" {s:<16} ", .{lot_sym}); - try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym}); - try out.print(" ratio {s} -> ", .{cur_str}); - try cli.printBold(out, color, "{s}", .{sug_str}); - try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str}); - - break; // One suggestion per symbol - } - } - } - - if (has_header) try out.print("\n", .{}); -} - -/// Direct-indexing ratio suggestions from Schwab-summary data. -/// -/// The Schwab summary path only gives us per-account totals, not -/// per-symbol detail. For a direct-indexing account with exactly one -/// stock lot (the common case — the account is the proxy basket, -/// tracked as a single benchmark lot), we can still emit a ratio -/// suggestion from the account-level `total_delta`: -/// -/// current_stock_value = portfolio_total - portfolio_cash -/// target_stock_value = current_stock_value + total_delta -/// suggested_ratio = target_stock_value / (shares × price) -/// -/// Where `price` is `shares × current_cached_price`. The math -/// assumes the full account delta lands on the single tracked lot, -/// which is the semantics of a direct-indexing proxy. -/// -/// Skips accounts with more than one stock lot (can't allocate the -/// delta) or zero stock lots (nothing to adjust). -fn displaySchwabSummaryRatioSuggestions( - results: []const SchwabAccountComparison, - portfolio: zfin.Portfolio, - prices: std.StringHashMap(f64), - account_map: ?analysis.AccountMap, - color: bool, - out: *std.Io.Writer, -) !void { - const am = account_map orelse return; - var has_header = false; - - for (results) |r| { - if (r.account_name.len == 0) continue; - if (!am.isDirectIndexing(r.account_name)) continue; - const total_delta = r.total_delta orelse continue; - if (@abs(total_delta) < 0.01) continue; - - // Find the single stock lot for this account. - var stock_lot: ?zfin.Lot = null; - var stock_lot_count: usize = 0; - for (portfolio.lots) |lot| { - if (lot.security_type != .stock) continue; - const lot_acct = lot.account orelse continue; - if (!std.mem.eql(u8, lot_acct, r.account_name)) continue; - stock_lot = lot; - stock_lot_count += 1; - } - if (stock_lot_count != 1) continue; - const lot = stock_lot.?; - - const price_sym = lot.priceSymbol(); - const retail_price = prices.get(price_sym) orelse continue; - if (retail_price == 0) continue; - if (lot.shares == 0) continue; - - const current_stock_value = lot.shares * retail_price * lot.price_ratio; - if (current_stock_value == 0) continue; - const target_stock_value = current_stock_value + total_delta; - const suggested_ratio = target_stock_value / (lot.shares * retail_price); - const drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0; - - if (!has_header) { - try out.print("\n", .{}); - try cli.printBold(out, color, " Ratio updates", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf; direct-indexing accounts)\n", .{}); - has_header = true; - } - - var cur_buf: [24]u8 = undefined; - var sug_buf: [24]u8 = undefined; - var drift_buf: [16]u8 = undefined; - const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{lot.price_ratio}) catch "?"; - const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?"; - const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.4}%", .{drift_pct}) catch "?"; - - try out.print(" {s:<16} ", .{lot.symbol}); - try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym}); - try out.print(" ratio {s} -> ", .{cur_str}); - try cli.printBold(out, color, "{s}", .{sug_str}); - try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str}); - } - - if (has_header) try out.print("\n", .{}); -} - -// ── Display ───────────────────────────────────────────────── - -fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void { - try cli.printBold(out, color, "\nPortfolio Audit", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{}); - try out.print("========================================\n\n", .{}); - - var total_portfolio: f64 = 0; - var total_brokerage: f64 = 0; - var total_option_delta: f64 = 0; - var discrepancy_count: usize = 0; - - for (results) |acct| { - // Account header - if (acct.account_name.len > 0) { - try cli.printBold(out, color, " {s}", .{acct.account_name}); - try cli.printFg(out, color, cli.CLR_MUTED, " ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number }); - } else { - try cli.printBold(out, color, " {s} #{s}", .{ acct.brokerage_name, acct.account_number }); - try cli.printFg(out, color, cli.CLR_WARNING, " (unmapped — add account_number to accounts.srf)\n", .{}); - } - - // Column headers - try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {s:>10} {s:>10} {s}\n", .{ - "Symbol", "PF Shares", "BR Shares", "PF Price", "BR Price", "", - }); - - for (acct.comparisons) |cmp| { - var pf_shares_buf: [16]u8 = undefined; - var br_shares_buf: [16]u8 = undefined; - var pf_price_buf: [16]u8 = undefined; - var br_price_buf: [16]u8 = undefined; - - // Classify this row - const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage; - const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= cash_tolerance else false); - const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch; - - if (is_real_mismatch) discrepancy_count += 1; - - // Format share strings - const pf_shares_str: []const u8 = if (cmp.is_cash) - (std.fmt.bufPrint(&pf_shares_buf, "{f}", .{Money.from(cmp.portfolio_value)}) catch "$?") - else - std.fmt.bufPrint(&pf_shares_buf, "{d:.3}", .{cmp.portfolio_shares}) catch "?"; - - const br_shares_str: []const u8 = if (cmp.is_cash) - (if (cmp.brokerage_value) |v| (std.fmt.bufPrint(&br_shares_buf, "{f}", .{Money.from(v)}) catch "$?") else "--") - else if (cmp.brokerage_shares) |s| - (std.fmt.bufPrint(&br_shares_buf, "{d:.3}", .{s}) catch "?") - else - "--"; - - const pf_price_str: []const u8 = if (cmp.is_cash) - "" - else if (cmp.portfolio_price) |p| - (std.fmt.bufPrint(&pf_price_buf, "{d:.2}", .{p}) catch "?") - else - "--"; - - const br_price_str: []const u8 = if (cmp.is_cash) - "" - else if (cmp.brokerage_price) |p| - (std.fmt.bufPrint(&br_price_buf, "{d:.2}", .{p}) catch "?") - else - "--"; - - // Determine PF Shares color (relative to brokerage) - const shares_color: enum { normal, positive, negative, warning } = blk: { - if (cmp.only_in_portfolio) break :blk .warning; - if (cmp.is_cash and is_cash_mismatch) { - break :blk if (cmp.value_delta.? > 0) .negative else .positive; - } - if (shares_ok) break :blk .normal; - if (cmp.shares_delta) |d| { - break :blk if (d > 0) .negative else .positive; - } - break :blk .normal; - }; - - // Determine status text - var status_buf: [64]u8 = undefined; - const status: []const u8 = blk: { - if (cmp.only_in_brokerage) break :blk "Brokerage only"; - if (cmp.only_in_portfolio) break :blk "Portfolio only"; - - if (is_cash_mismatch) { - if (cmp.value_delta) |d| { - const sign: []const u8 = if (d >= 0) "+" else "-"; - break :blk std.fmt.bufPrint(&status_buf, "Cash {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "Cash mismatch"; - } - } - - if (!shares_ok) { - if (cmp.shares_delta) |d| { - const sign: []const u8 = if (d > 0) "+" else ""; - break :blk std.fmt.bufPrint(&status_buf, "Shares {s}{d:.3}", .{ sign, d }) catch "Shares mismatch"; - } - } - - // Options: shares match is sufficient — value delta is expected - // (cost basis vs mark-to-market) and not actionable - if (cmp.is_option) break :blk "Option"; - - // Shares match — show value delta (stale price) if any, muted - if (cmp.value_delta) |d| { - if (@abs(d) >= value_tolerance) { - const sign: []const u8 = if (d >= 0) "+" else "-"; - break :blk std.fmt.bufPrint(&status_buf, "Value {s}{f}", .{ sign, Money.from(@abs(d)) }) catch ""; - } - } - - break :blk ""; - }; - - // Status color: real mismatches in warning, stale values muted, ok is blank - const status_color: enum { warning, muted, none } = blk: { - if (is_real_mismatch) break :blk .warning; - if (status.len > 0) break :blk .muted; - break :blk .none; - }; - - // Print symbol - try out.print(" {s:<24} ", .{cmp.symbol}); - - // Print PF Shares with color - switch (shares_color) { - .positive => try cli.printFg(out, color, cli.CLR_POSITIVE, "{s:>12}", .{pf_shares_str}), - .negative => try cli.printFg(out, color, cli.CLR_NEGATIVE, "{s:>12}", .{pf_shares_str}), - .warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s:>12}", .{pf_shares_str}), - .normal => try out.print("{s:>12}", .{pf_shares_str}), - } - - // Print BR Shares, prices - try out.print(" {s:>12} {s:>10} {s:>10} ", .{ br_shares_str, pf_price_str, br_price_str }); - - // Print status - switch (status_color) { - .warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s}\n", .{status}), - .muted => try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{status}), - .none => try out.print("{s}\n", .{status}), - } - } - - // Account totals - try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {f} {f} ", .{ - "", "", "", Money.from(acct.portfolio_total).padRight(10), Money.from(acct.brokerage_total).padRight(10), - }); - - const adj_delta = acct.total_delta - acct.option_value_delta; - if (@abs(adj_delta) < 1.0) { - // no delta text needed - } else { - const sign: []const u8 = if (adj_delta >= 0) "+" else "-"; - const rgb = if (adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; - try cli.printFg(out, color, rgb, "Delta {s}{f}", .{ sign, Money.from(@abs(adj_delta)) }); - } - - if (@abs(acct.option_value_delta) >= 1.0) { - const opt_sign: []const u8 = if (acct.option_value_delta >= 0) "+" else "-"; - try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) }); - } - try out.print("\n\n", .{}); - - total_portfolio += acct.portfolio_total; - total_brokerage += acct.brokerage_total; - total_option_delta += acct.option_value_delta; - } - - // Grand totals - const grand_delta = total_brokerage - total_portfolio; - const grand_adj_delta = grand_delta - total_option_delta; - - try cli.printBold(out, color, " Total: portfolio {f} brokerage {f}", .{ - Money.from(total_portfolio), - Money.from(total_brokerage), - }); - - if (@abs(grand_adj_delta) < 1.0) { - // no delta text needed - } else { - const sign: []const u8 = if (grand_adj_delta >= 0) "+" else "-"; - const rgb = if (grand_adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; - try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_adj_delta)) }); - } - - if (@abs(total_option_delta) >= 1.0) { - const opt_sign: []const u8 = if (total_option_delta >= 0) "+" else "-"; - try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(total_option_delta)) }); - } - try out.print("\n", .{}); - - if (discrepancy_count > 0) { - try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} to investigate\n", .{ discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches") }); - } - try out.print("\n", .{}); -} - -/// Render the "portfolio accounts not found in this export" advisory. -/// Silent when `absent` is empty, so it composes cleanly after both -/// the verbose audit table and the compact "no discrepancies" line. -fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, out: *std.Io.Writer) !void { - if (absent.len == 0) return; - - try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_WARNING, " Portfolio accounts not found in this export", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " (stale, or not exported?)\n", .{}); - for (absent) |a| { - try out.print(" ", .{}); - try cli.printFg(out, color, cli.CLR_WARNING, "{s}", .{a.account_name}); - try cli.printFg(out, color, cli.CLR_MUTED, " (#{s})", .{a.account_number}); - try out.print(" {f}\n", .{Money.from(a.portfolio_total)}); - } - try out.print("\n", .{}); -} - -// ── Hygiene check (flagless audit) ────────────────────────── - -/// Constants for hygiene check behavior. Kept as named constants for -/// easy future tuning. -const audit_file_max_age_hours = 24; -const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only -const default_stale_days: u32 = 3; -const stale_warning_multiplier: u32 = 2; // yellow → red at 2× threshold - -/// Dollar threshold above which a new lot (new_stock / new_drip_lot / -/// new_cash / new_cd / cash_contribution) gets flagged in the -/// "Large new lots — confirm source" hygiene section. Below this -/// threshold new lots pass silently — the audit's goal is to catch -/// unconfirmed six-figure movements, not flag every payroll -/// contribution. -/// -/// $10k is a judgment call: high enough to ignore routine payroll -/// ESPP accruals and $1-$2k weekly deposits, low enough to surface -/// a typical IRA contribution or a genuine transfer. Tunable here, -/// per the plan's "revisit if the threshold proves wrong" note in -/// TODO.md. -const audit_large_lot_threshold: f64 = 10_000.0; - -/// Type of a discovered brokerage file. -const BrokerFileKind = enum { - fidelity_csv, - schwab_csv, - schwab_summary, -}; - -/// A discovered brokerage file ready for reconciliation. -const DiscoveredFile = struct { - path: []const u8, - kind: BrokerFileKind, - dir_label: []const u8, // e.g. "audit/" or "$ZFIN_AUDIT_FILES" -}; - -/// Detect the brokerage type from file contents by inspecting the first few lines. -fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind { - // Strip optional UTF-8 BOM - const content = if (data.len >= 3 and data[0] == 0xEF and data[1] == 0xBB and data[2] == 0xBF) - data[3..] - else - data; - - // Fidelity CSV: first line starts with "Account Number" or "Account Name" - if (std.mem.startsWith(u8, content, "Account Number") or - std.mem.startsWith(u8, content, "Account Name")) - return .fidelity_csv; - - // Schwab per-account CSV: starts with a quoted title line like "Positions for ..." - if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv; - - // Schwab summary: contains "Account number ending in" pattern - const peek = content[0..@min(content.len, 4096)]; - if (std.mem.indexOf(u8, peek, "Account number ending in") != null) return .schwab_summary; - // Also match by account type labels + dollar amounts - if ((std.mem.indexOf(u8, peek, "Brokerage") != null or - std.mem.indexOf(u8, peek, "Roth IRA") != null or - std.mem.indexOf(u8, peek, "Traditional IRA") != null or - std.mem.indexOf(u8, peek, "Rollover IRA") != null) and - std.mem.indexOf(u8, peek, "$") != null) - { - return .schwab_summary; - } - - return null; -} - -/// Discover brokerage files in a directory. Filters by recency (< 24h) -/// and applies size limits for non-CSV files. -fn discoverBrokerFiles( - io: std.Io, - allocator: std.mem.Allocator, - dir_path: []const u8, - dir_label: []const u8, - now_s: i64, -) ![]DiscoveredFile { - var results = std.ArrayList(DiscoveredFile).empty; - defer results.deinit(allocator); - - var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return try results.toOwnedSlice(allocator); - defer dir.close(io); - - const max_age_s: i128 = audit_file_max_age_hours * 3600; - - var it = dir.iterate(); - while (try it.next(io)) |entry| { - if (entry.kind != .file) continue; - - // Check file modification time - const stat = dir.statFile(io, entry.name, .{}) catch continue; - const mtime_s: i128 = @divFloor(stat.mtime.nanoseconds, std.time.ns_per_s); - const age_s = now_s - mtime_s; - if (age_s > max_age_s) continue; - - // Check if it's a CSV (no size limit) or non-CSV (size limit applies) - const is_csv = std.mem.endsWith(u8, entry.name, ".csv") or std.mem.endsWith(u8, entry.name, ".CSV"); - if (!is_csv and stat.size > audit_file_max_size_non_csv) continue; - - // Read and detect content type - const data = dir.readFileAlloc(io, entry.name, allocator, .limited(10 * 1024 * 1024)) catch continue; - defer allocator.free(data); - - const kind = detectBrokerFileKind(data) orelse continue; - const full_path = std.fs.path.join(allocator, &.{ dir_path, entry.name }) catch continue; - try results.append(allocator, .{ - .path = full_path, - .kind = kind, - .dir_label = dir_label, - }); - } - - return results.toOwnedSlice(allocator); -} - -/// Compute which accounts have been modified between two parsed portfolios. -/// Returns a set of account names that have any lot-level differences. -/// Compares by serializing each lot to a canonical string per account, -/// sorting, and checking for equality. Simple and robust -- any field -/// change in a lot produces a different string. -fn findModifiedAccounts( - allocator: std.mem.Allocator, - old_portfolio: zfin.Portfolio, - new_portfolio: zfin.Portfolio, -) !std.StringHashMap(void) { - var modified = std.StringHashMap(void).init(allocator); - errdefer modified.deinit(); - - // Collect serialized lot strings grouped by account - var old_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator); - defer { - var it = old_accts.valueIterator(); - while (it.next()) |v| { - for (v.items) |s| allocator.free(s); - v.deinit(allocator); - } - old_accts.deinit(); - } - var new_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator); - defer { - var it = new_accts.valueIterator(); - while (it.next()) |v| { - for (v.items) |s| allocator.free(s); - v.deinit(allocator); - } - new_accts.deinit(); - } - - for (old_portfolio.lots) |lot| { - const acct = lot.account orelse continue; - const entry = try old_accts.getOrPut(acct); - if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty; - try entry.value_ptr.append(allocator, try lotToString(allocator, lot)); - } - for (new_portfolio.lots) |lot| { - const acct = lot.account orelse continue; - const entry = try new_accts.getOrPut(acct); - if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty; - try entry.value_ptr.append(allocator, try lotToString(allocator, lot)); - } - - // Compare per account: sort both lists, then check equality - var all = std.StringHashMap(void).init(allocator); - defer all.deinit(); - { - var it = old_accts.keyIterator(); - while (it.next()) |k| try all.put(k.*, {}); - } - { - var it = new_accts.keyIterator(); - while (it.next()) |k| try all.put(k.*, {}); - } - - var acct_it = all.keyIterator(); - while (acct_it.next()) |acct_key| { - const acct = acct_key.*; - const old_ptr = old_accts.getPtr(acct); - const new_ptr = new_accts.getPtr(acct); - const old_len = if (old_ptr) |p| p.items.len else 0; - const new_len = if (new_ptr) |p| p.items.len else 0; - - if (old_len != new_len) { - try modified.put(acct, {}); - continue; - } - if (old_len == 0) continue; - - const old_items = old_ptr.?.items; - const new_items = new_ptr.?.items; - std.mem.sort([]const u8, old_items, {}, strLessThan); - std.mem.sort([]const u8, new_items, {}, strLessThan); - - var differs = false; - for (old_items, new_items) |a, b| { - if (!std.mem.eql(u8, a, b)) { - differs = true; - break; - } - } - if (differs) try modified.put(acct, {}); - } - - return modified; -} - -fn strLessThan(_: void, a: []const u8, b: []const u8) bool { - return std.mem.order(u8, a, b) == .lt; -} - -const srf = @import("srf"); - -/// Serialize a lot to a canonical SRF string for comparison. -/// Uses the SRF serializer with comptime reflection, so any new -/// field added to Lot is automatically included. -fn lotToString(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 { - const lots = [_]portfolio_mod.Lot{lot}; - return std.fmt.allocPrint(allocator, "{f}", .{srf.fmt(portfolio_mod.Lot, &lots, .{ .emit_directives = false })}); -} - -/// Staleness color based on age vs threshold. -/// Returns CLR_MUTED for within threshold, warning for 1-2x, negative for >2x. -fn stalenessColor(age_days: i32, threshold: u32) [3]u8 { - const t: i32 = @intCast(threshold); - if (age_days <= t) return cli.CLR_MUTED; - if (age_days <= t * @as(i32, stale_warning_multiplier)) return cli.CLR_WARNING; - return cli.CLR_NEGATIVE; -} - -/// One stale (or undated) manual price found during the hygiene scan. -/// String fields borrow from the scanned portfolio's lots and are -/// valid for the lifetime of that portfolio. -const StaleManualPrice = struct { - account: []const u8, - symbol: []const u8, - note: ?[]const u8, - price: f64, - /// `null` when the lot carries a manual `price` but no - /// `price_date` — the most-stale case (it can't even be aged), - /// not the least. - price_date: ?Date, - /// Days since `price_date`; `null` when undated. - age_days: ?i32, -}; - -/// Collect manual-priced lots that are stale (older than `stale_days`) -/// or undated, for the "Stale manual prices" hygiene section. -/// -/// Staleness is purely a property of the `price` / `price_date` the -/// user typed on the lot — it has nothing to do with the account's -/// `update_cadence` (that's the reconciliation cadence, a separate -/// concept handled by the "Accounts overdue" section). The single -/// threshold is `stale_days` (the `--stale-days` flag, default 3). -/// -/// Restricted to open `security_type == .stock` lots: CDs/cash/options -/// carry `price` as a fixed face value that never goes "stale by age," -/// and closed lots aren't worth nagging about. Undated manual prices -/// are always included regardless of `stale_days` — a manual price -/// with no `price_date` can't be aged, which is the worst case, not a -/// pass. Caller owns the returned list; string fields borrow from -/// `portfolio`. -fn collectStaleManualPrices( - allocator: std.mem.Allocator, - portfolio: zfin.Portfolio, - as_of: Date, - stale_days: u32, -) !std.ArrayList(StaleManualPrice) { - var out = std.ArrayList(StaleManualPrice).empty; - errdefer out.deinit(allocator); - - const threshold: i32 = @intCast(stale_days); - for (portfolio.lots) |lot| { - if (lot.security_type != .stock) continue; - const price = lot.price orelse continue; - if (!lot.isOpen(as_of)) continue; - const account = lot.account orelse "(no account)"; - - if (lot.price_date) |pd| { - const age = as_of.days - pd.days; - if (age <= threshold) continue; // fresh enough - try out.append(allocator, .{ - .account = account, - .symbol = lot.symbol, - .note = lot.note, - .price = price, - .price_date = pd, - .age_days = age, - }); - } else { - try out.append(allocator, .{ - .account = account, - .symbol = lot.symbol, - .note = lot.note, - .price = price, - .price_date = null, - .age_days = null, - }); - } - } - return out; -} - -/// Sort stale manual prices by account, then symbol — so the display -/// can group lines under per-account headers. -fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool { - const acc = std.mem.order(u8, a.account, b.account); - if (acc != .eq) return acc == .lt; - return std.mem.order(u8, a.symbol, b.symbol) == .lt; -} - -/// A lot whose manual `price` moved between HEAD and the working tree -/// while its `price_date` stayed identical — the "bumped the price, -/// forgot the date" mistake. String fields borrow from the working- -/// tree portfolio. -const PriceDateMismatch = struct { - account: []const u8, - symbol: []const u8, - old_price: f64, - new_price: f64, - price_date: Date, -}; - -/// Build a lot identity that survives a manual-price edit: a lot keeps -/// its symbol, account, open_date, and open_price when you only change -/// `price`/`price_date`. Used to pair HEAD lots with working-tree lots. -/// Caller owns the returned slice. -fn lotIdentityKey(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 { - return std.fmt.allocPrint(allocator, "{s}\x00{s}\x00{d}\x00{d:.6}", .{ - lot.symbol, - lot.account orelse "", - lot.open_date.days, - lot.open_price, - }); -} - -/// Find lots whose manual `price` changed between `committed` (HEAD) -/// and `working` (on-disk) while `price_date` stayed identical. -/// -/// This is the working-tree-vs-HEAD detector for the recurring "I -/// updated the price but forgot the date" mistake, run when `audit` -/// fires before a commit. Only open `security_type == .stock` lots -/// with a non-null `price_date` on both sides participate: the undated -/// case is reported by `collectStaleManualPrices`, and legitimate -/// back-dating (moving the date to a past close) is *not* flagged -/// because the date field changed. Lots are paired by -/// `lotIdentityKey`; newly-added or removed lots are ignored. Caller -/// owns the returned list; string fields borrow from `working`. -fn findPriceDateMismatches( - allocator: std.mem.Allocator, - committed: zfin.Portfolio, - working: zfin.Portfolio, - as_of: Date, -) !std.ArrayList(PriceDateMismatch) { - var out = std.ArrayList(PriceDateMismatch).empty; - errdefer out.deinit(allocator); - - // Index HEAD lots by stable identity → their (price, price_date). - const HeadLot = struct { price: ?f64, price_date: ?Date }; - var head = std.StringHashMap(HeadLot).init(allocator); - defer { - var it = head.keyIterator(); - while (it.next()) |k| allocator.free(k.*); - head.deinit(); - } - for (committed.lots) |lot| { - if (lot.security_type != .stock) continue; - const key = try lotIdentityKey(allocator, lot); - const gop = try head.getOrPut(key); - if (gop.found_existing) { - // Duplicate identity (rare) — ambiguous, don't guess. - allocator.free(key); - continue; - } - gop.value_ptr.* = .{ .price = lot.price, .price_date = lot.price_date }; - } - - for (working.lots) |lot| { - if (lot.security_type != .stock) continue; - if (!lot.isOpen(as_of)) continue; - const new_price = lot.price orelse continue; - const new_date = lot.price_date orelse continue; // undated → stale-price section's job - const key = try lotIdentityKey(allocator, lot); - defer allocator.free(key); - const prior = head.get(key) orelse continue; // newly-added lot - const old_price = prior.price orelse continue; // price added, not moved - const old_date = prior.price_date orelse continue; // was undated before - // The mistake: price moved, date did not. - if (old_date.days == new_date.days and @abs(old_price - new_price) >= 0.005) { - try out.append(allocator, .{ - .account = lot.account orelse "(no account)", - .symbol = lot.symbol, - .old_price = old_price, - .new_price = new_price, - .price_date = new_date, - }); - } - } - return out; -} - -/// Sort price/date mismatches by account, then symbol — for grouped -/// per-account display. -fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool { - const acc = std.mem.order(u8, a.account, b.account); - if (acc != .eq) return acc == .lt; - return std.mem.order(u8, a.symbol, b.symbol) == .lt; -} - -/// Render one unmatched large-lot warning. Formats the line the -/// user needs to paste into `transaction_log.srf` if the lot was -/// an internal movement rather than a real external contribution. -/// Leaves `from::` as a placeholder — the audit doesn't -/// know which account the money came from. -/// -/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash -/// (or cash_contribution) destinations use `dest_lot::cash`. The -/// template always uses `type::cash` since `type::in_kind` is -/// rejected downstream in v1. -fn printLargeLotWarning( - out: *std.Io.Writer, - lot: contributions.UnmatchedLargeLot, - color: bool, -) !void { - var val_buf: [32]u8 = undefined; - var date_buf: [10]u8 = undefined; - const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?"; - const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??"; - const kind_label: []const u8 = switch (lot.security_type) { - .stock => "STOCK", - .cash => "CASH", - .cd => "CD", - .option => "OPTION", - else => "LOT", - }; - - const sym_for_display = if (lot.symbol.len > 0) lot.symbol else "cash"; - try out.print( - " {s}: new {s} lot {s} ", - .{ lot.account, kind_label, sym_for_display }, - ); - try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str}); - try out.print(" on {s}\n", .{date_str}); - - try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{}); - - // Amount formatted with cents precision so the suggested - // `amount:num:N` exactly matches the lot's value. The matcher - // has a $1 tolerance so a whole-dollar suggestion would usually - // pair, but pasting a value that lies about the actual lot is - // a poor user experience — `transaction_log.srf` should record - // what actually moved. - if (lot.security_type == .cash) { - try cli.printFg( - out, - color, - cli.CLR_MUTED, - " transfer::{s},type::cash,amount:num:{d:.2},from::,to::{s},dest_lot::cash\n", - .{ date_str, lot.value, lot.account }, - ); - } else { - try cli.printFg( - out, - color, - cli.CLR_MUTED, - " transfer::{s},type::cash,amount:num:{d:.2},from::,to::{s},dest_lot::{s}@{s}\n", - .{ date_str, lot.value, lot.account, lot.symbol, date_str }, - ); - } -} - -/// Run the flagless portfolio hygiene check. -fn runHygieneCheck( - io: std.Io, - allocator: std.mem.Allocator, - env: *const std.process.Environ.Map, - svc: *zfin.DataService, - portfolio_path: []const u8, - stale_days: u32, - verbose: bool, - as_of: Date, - now_s: i64, - color: bool, - refresh: framework.RefreshPolicy, - out: *std.Io.Writer, -) !void { - // Load portfolio - const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch { - cli.stderrPrint(io, "Error: Cannot read portfolio file\n"); - return; - }; - defer allocator.free(pf_data); - - var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch { - cli.stderrPrint(io, "Error: Cannot parse portfolio file\n"); - return; - }; - defer portfolio.deinit(); - - // Load accounts.srf - var account_map = svc.loadAccountMap(allocator, portfolio_path) orelse { - cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n"); - return; - }; - defer account_map.deinit(); - - try cli.printBold(out, color, " Portfolio hygiene\n", .{}); - - // ── Section 1: Stale manual prices ── - // - // Manual prices on stock/fund lots whose `price_date` is older than - // `--stale-days` (default 3), plus manual prices with no - // `price_date` at all (the most-stale case). Staleness is a - // property of the price the user typed on the lot — not of the - // account; grouping by account here is display-only organization. - // CDs/cash/options are excluded — their `price` is a fixed face - // value, not an age-stale quote — as are closed lots. - { - var stale = try collectStaleManualPrices(allocator, portfolio, as_of, stale_days); - defer stale.deinit(allocator); - std.mem.sort(StaleManualPrice, stale.items, {}, staleLessThan); - - try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days — --stale-days to configure)\n", .{stale_days}); - - if (stale.items.len == 0) { - try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{}); - } else { - var current_account: ?[]const u8 = null; - for (stale.items) |e| { - if (current_account == null or !std.mem.eql(u8, current_account.?, e.account)) { - current_account = e.account; - try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{e.account}); - } - - var price_buf: [24]u8 = undefined; - const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(e.price)}) catch "$?"; - const note_display = e.note orelse ""; - - if (e.price_date) |pd| { - var date_buf: [10]u8 = undefined; - const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{pd}) catch "????-??-??"; - try out.print(" {s:<14} {s:<16} {s:>12} {s} ", .{ e.symbol, note_display, price_str, date_str }); - const clr = stalenessColor(e.age_days.?, stale_days); - try cli.printFg(out, color, clr, "({d} days)\n", .{@as(u32, @intCast(e.age_days.?))}); - } else { - try out.print(" {s:<14} {s:<16} {s:>12} ", .{ e.symbol, note_display, price_str }); - try cli.printFg(out, color, cli.CLR_NEGATIVE, "(no price_date set)\n", .{}); - } - } - } - } - - // ── Section 2: Account cadence check ── - { - // Try to get committed version via git - const git = @import("../git.zig"); - const repo_info: ?git.RepoInfo = git.findRepo(io, allocator, env, portfolio_path) catch null; - defer if (repo_info) |ri| { - allocator.free(ri.root); - allocator.free(ri.rel_path); - }; - - // Parse committed portfolio for diff (working copy vs HEAD) - var committed_portfolio: ?zfin.Portfolio = null; - defer if (committed_portfolio) |*cp| cp.deinit(); - - var committed_data: ?[]const u8 = null; - defer if (committed_data) |d| allocator.free(d); - - if (repo_info) |ri| { - committed_data = git.show(io, allocator, env, ri.root, "HEAD", ri.rel_path) catch null; - if (committed_data) |cd| { - committed_portfolio = zfin.cache.deserializePortfolio(allocator, cd) catch null; - } - } - - // ── Section 1b: manual price changed without bumping price_date ── - // - // Catches the recurring "I updated the price but forgot the - // date" mistake at the moment it matters — when `audit` runs - // against the working tree before a commit. Diffs the on-disk - // portfolio against HEAD; a lot whose `price` moved while its - // `price_date` stayed put is flagged. Silent when there's no - // committed version to compare against (not a repo / new file) - // and when no such mismatch exists. - if (committed_portfolio) |cp| { - var mismatches = try findPriceDateMismatches(allocator, cp, portfolio, as_of); - defer mismatches.deinit(allocator); - std.mem.sort(PriceDateMismatch, mismatches.items, {}, mismatchLessThan); - - if (mismatches.items.len > 0) { - try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " Manual price changed without updating price_date (working tree vs HEAD)\n", .{}); - var current_account: ?[]const u8 = null; - for (mismatches.items) |m| { - if (current_account == null or !std.mem.eql(u8, current_account.?, m.account)) { - current_account = m.account; - try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{m.account}); - } - var old_buf: [24]u8 = undefined; - var new_buf: [24]u8 = undefined; - var date_buf: [10]u8 = undefined; - const old_str = std.fmt.bufPrint(&old_buf, "{f}", .{Money.from(m.old_price)}) catch "$?"; - const new_str = std.fmt.bufPrint(&new_buf, "{f}", .{Money.from(m.new_price)}) catch "$?"; - const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{m.price_date}) catch "????-??-??"; - try out.print(" {s:<14} {s} → {s} ", .{ m.symbol, old_str, new_str }); - try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} — bump it\n", .{date_str}); - } - } - } - - // Find accounts modified in working copy (uncommitted changes) - var working_copy_modified = std.StringHashMap(void).init(allocator); - defer working_copy_modified.deinit(); - - if (committed_portfolio) |cp| { - working_copy_modified = findModifiedAccounts(allocator, cp, portfolio) catch std.StringHashMap(void).init(allocator); - } - - // Collect all unique account names from working copy portfolio - // (these pointers are stable for the lifetime of the function) - var all_accounts = std.StringHashMap(void).init(allocator); - defer all_accounts.deinit(); - for (portfolio.lots) |lot| { - if (lot.account) |acct| { - try all_accounts.put(acct, {}); - } - } - - // Find last update time for each account via git history. - // Walk commits newest-to-oldest, diffing adjacent pairs to find - // which accounts changed. Use working-copy account names as keys - // (stable lifetime) rather than historical portfolio strings. - // Only walk back far enough to hit red status (2× max cadence). - var last_update_ts = std.StringHashMap(i64).init(allocator); - defer last_update_ts.deinit(); - - if (repo_info) |ri| { - // Compute the furthest we need to look back: 2× the max cadence - var max_threshold: u32 = 14; // 2× weekly default - for (account_map.entries) |entry| { - if (entry.update_cadence.thresholdDays()) |td| { - const red = td * stale_warning_multiplier; - if (red > max_threshold) max_threshold = red; - } - } - var since_buf: [32]u8 = undefined; - const since = std.fmt.bufPrint(&since_buf, "{d} days ago", .{max_threshold}) catch "30 days ago"; - - const commits = git.listCommitsTouching(io, allocator, env, ri.root, ri.rel_path, since) catch &.{}; - defer git.freeCommitTouches(allocator, commits); - - var prev_data: ?[]const u8 = null; - defer if (prev_data) |pd| allocator.free(pd); - - for (commits, 0..) |ct, ci| { - // Stop early if every account already has a timestamp - if (last_update_ts.count() >= all_accounts.count()) break; - - const rev_data = git.show(io, allocator, env, ri.root, ct.commit, ri.rel_path) catch continue; - - if (ci > 0) { - if (prev_data) |pd| { - // rev_data is older, pd is newer (commits are newest-first) - var old_pf = zfin.cache.deserializePortfolio(allocator, rev_data) catch { - allocator.free(rev_data); - continue; - }; - defer old_pf.deinit(); - var new_pf = zfin.cache.deserializePortfolio(allocator, pd) catch continue; - defer new_pf.deinit(); - - var mods = findModifiedAccounts(allocator, old_pf, new_pf) catch continue; - defer mods.deinit(); - - // The newer commit's timestamp is when these accounts were updated - const update_ts = commits[ci - 1].timestamp; - - // Match against stable working-copy account names - var acct_iter = all_accounts.keyIterator(); - while (acct_iter.next()) |stable_name| { - if (last_update_ts.contains(stable_name.*)) continue; - if (mods.contains(stable_name.*)) { - try last_update_ts.put(stable_name.*, update_ts); - } - } - } - } - - if (prev_data) |pd| allocator.free(pd); - prev_data = rev_data; - } - } - - // Display overdue accounts - var overdue_header_shown = false; - var updated_accounts = std.ArrayList([]const u8).empty; - defer updated_accounts.deinit(allocator); - - // Check accounts updated in working copy - var wc_it = working_copy_modified.keyIterator(); - while (wc_it.next()) |key| { - try updated_accounts.append(allocator, key.*); - } - - // Check overdue accounts - var acct_it = all_accounts.keyIterator(); - while (acct_it.next()) |acct_key| { - const acct_name = acct_key.*; - - // Skip if already updated in working copy - if (working_copy_modified.contains(acct_name)) continue; - - // Look up cadence from accounts.srf - var cadence = analysis.UpdateCadence.weekly; // default - for (account_map.entries) |entry| { - if (std.mem.eql(u8, entry.account, acct_name)) { - cadence = entry.update_cadence; - break; - } - } - - const threshold_days = cadence.thresholdDays() orelse continue; // skip 'none' - - // Find last update time - var age_days: ?i32 = null; - if (last_update_ts.get(acct_name)) |ts| { - const age_s = now_s - ts; - age_days = @intCast(@divFloor(age_s, std.time.s_per_day)); - } - - // If we have no git history for this account, it's definitely overdue - const days = age_days orelse @as(i32, @intCast(threshold_days + 1)); - if (days <= @as(i32, @intCast(threshold_days))) continue; - - if (!overdue_header_shown) { - try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default — set update_cadence in accounts.srf)\n", .{}); - overdue_header_shown = true; - } - - try out.print(" {s:<32} {s:<10}", .{ acct_name, cadence.label() }); - if (age_days) |ad| { - const clr = stalenessColor(ad, threshold_days); - try cli.printFg(out, color, clr, "last updated {d} days ago\n", .{@as(u32, @intCast(ad))}); - } else { - try cli.printFg(out, color, cli.CLR_NEGATIVE, "no update history found\n", .{}); - } - } - - // Display accounts updated in working copy - if (updated_accounts.items.len > 0) { - try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " Accounts updated (working copy)\n", .{}); - for (updated_accounts.items) |acct| { - try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}\n", .{acct}); - } - } - } - - // ── Section 3: Discover brokerage files ── - - // Resolve audit directories - const portfolio_dir = std.fs.path.dirnamePosix(portfolio_path) orelse "."; - - var all_files = std.ArrayList(DiscoveredFile).empty; - defer { - for (all_files.items) |f| allocator.free(f.path); - all_files.deinit(allocator); - } - - // Check $ZFIN_AUDIT_FILES first - const env_audit_dir = if (svc.config.environ_map) |em| em.get("ZFIN_AUDIT_FILES") else null; - if (env_audit_dir) |edir| { - const env_files = try discoverBrokerFiles(io, allocator, edir, "$ZFIN_AUDIT_FILES", now_s); - defer allocator.free(env_files); - for (env_files) |f| try all_files.append(allocator, f); - } - - // Then check {portfolio_dir}/audit/ - const default_audit_dir = std.fs.path.join(allocator, &.{ portfolio_dir, "audit" }) catch null; - defer if (default_audit_dir) |d| allocator.free(d); - - if (default_audit_dir) |adir| { - const dir_files = try discoverBrokerFiles(io, allocator, adir, "audit/", now_s); - defer allocator.free(dir_files); - for (dir_files) |f| try all_files.append(allocator, f); - } - - // Display discovered files - if (all_files.items.len > 0) { - try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " Brokerage files (last {d} hours)\n", .{audit_file_max_age_hours}); - for (all_files.items) |f| { - const kind_label: []const u8 = switch (f.kind) { - .fidelity_csv => "fidelity", - .schwab_csv => "schwab csv", - .schwab_summary => "schwab summary", - }; - try out.print(" {s:<52} {s}\n", .{ f.path, kind_label }); - } - } - - // ── Section 4: Auto-reconcile discovered files ── - - if (all_files.items.len > 0) { - // Build prices map (shared by all reconciliations) - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - { - const pos_syms = try portfolio.stockSymbols(allocator); - defer allocator.free(pos_syms); - if (pos_syms.len > 0) { - var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, refresh, color); - defer load_result.deinit(); - var pit = load_result.prices.iterator(); - while (pit.next()) |entry| { - try prices.put(entry.key_ptr.*, entry.value_ptr.*); - } - } - for (portfolio.lots) |lot| { - if (lot.price) |p| { - if (!prices.contains(lot.priceSymbol())) { - try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false)); - } - } - } - } - - try out.print("\n", .{}); - try cli.printBold(out, color, " Reconciliation\n", .{}); - - for (all_files.items) |f| { - const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue; - defer allocator.free(file_data); - - switch (f.kind) { - .schwab_summary => { - const schwab_accounts = parseSchwabSummary(allocator, file_data) catch continue; - defer allocator.free(schwab_accounts); - - const results = compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of) catch continue; - defer allocator.free(results); - - const present = try presentNumbers(allocator, SchwabAccountComparison, results); - defer allocator.free(present); - const absent = try findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); - defer allocator.free(absent); - - if (verbose or hasSchwabDiscrepancies(results)) { - try out.print("\n", .{}); - try displaySchwabResults(results, color, out); - try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); - } else { - var acct_count: usize = 0; - for (results) |r| { - if (r.account_name.len > 0) acct_count += 1; - } - try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count}); - // Always show ratio suggestions even in compact - // mode — direct-indexing drift may cause a - // non-zero delta that still deserves a nudge. - try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); - } - try displayAbsentAccounts(absent, color, out); - }, - .fidelity_csv => { - const brokerage_positions = parseFidelityCsv(allocator, file_data) catch continue; - defer allocator.free(brokerage_positions); - - const results = compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices, as_of) catch continue; - defer { - for (results) |r| allocator.free(r.comparisons); - allocator.free(results); - } - - const present = try presentNumbers(allocator, AccountComparison, results); - defer allocator.free(present); - const absent = try findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of); - defer allocator.free(absent); - - if (verbose or hasAccountDiscrepancies(results)) { - try out.print("\n", .{}); - try displayResults(results, color, out); - try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); - } else { - try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len}); - // Always show ratio suggestions even in compact mode - try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); - } - try displayAbsentAccounts(absent, color, out); - }, - .schwab_csv => { - const parsed = parseSchwabCsv(allocator, file_data) catch continue; - defer allocator.free(parsed.positions); - - const results = compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of) catch continue; - defer { - for (results) |r| allocator.free(r.comparisons); - allocator.free(results); - } - - const present = try presentNumbers(allocator, AccountComparison, results); - defer allocator.free(present); - const absent = try findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); - defer allocator.free(absent); - - if (verbose or hasAccountDiscrepancies(results)) { - try out.print("\n", .{}); - try displayResults(results, color, out); - try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); - } else { - try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len}); - try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); - } - try displayAbsentAccounts(absent, color, out); - }, - } - } - } - - // ── Section 5: Large new lots — confirm source ── - // - // Cross-check any new_* Change with value >= threshold against - // `transaction_log.srf` (via the shared contributions pipeline). - // Surfaces lots that look like significant external contributions - // OR unrecorded internal transfers — nudges the user to either - // confirm or add a transfer record. - // - // Silent when every large lot matched a transfer record, when - // there are no new lots at all, or when the pipeline can't run - // (not in a git repo). Threshold is a judgment call; see - // `audit_large_lot_threshold`. - if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_path, audit_large_lot_threshold, as_of, color, refresh)) |found| { - var found_mut = found; - defer found_mut.deinit(); - - if (found_mut.lots.len > 0) { - try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots — confirm source\n", .{}); - for (found_mut.lots) |lot| { - try printLargeLotWarning(out, lot, color); - } - } - } - - try out.print("\n", .{}); -} - -/// Check if any Schwab summary results have discrepancies. -fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool { - for (results) |r| { - if (r.has_discrepancy) return true; - } - return false; -} - -/// Check if any account comparison results have discrepancies. -fn hasAccountDiscrepancies(results: []const AccountComparison) bool { - for (results) |r| { - if (r.has_discrepancies) return true; - } - return false; -} +const common = @import("audit/common.zig"); +const fidelity = @import("audit/fidelity.zig"); +const schwab = @import("audit/schwab.zig"); +const hygiene = @import("audit/hygiene.zig"); // ── CLI entry point ───────────────────────────────────────── @@ -2098,7 +33,7 @@ pub const ParsedArgs = struct { schwab_csv: ?[]const u8 = null, schwab_summary: bool = false, verbose: bool = false, - stale_days: u32 = default_stale_days, + stale_days: u32 = hygiene.default_stale_days, }; pub const meta: framework.Meta = .{ @@ -2189,7 +124,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { if (fidelity_csv == null and schwab_csv == null and !schwab_summary) { const pf = ctx.resolvePortfolioPath(); defer pf.deinit(allocator); - return runHygieneCheck(io, allocator, ctx.environ_map, svc, pf.path, stale_days, verbose, as_of, now_s, color, ctx.globals.refresh_policy, out); + return hygiene.runHygieneCheck(io, allocator, ctx.environ_map, svc, pf.path, stale_days, verbose, as_of, now_s, color, ctx.globals.refresh_policy, out); } // Reconciliation modes (--fidelity / --schwab / --schwab-summary): @@ -2254,23 +189,23 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { }; defer allocator.free(stdin_data); - const schwab_accounts = parseSchwabSummary(allocator, stdin_data) catch { - cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n"); - return; + const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n"); + return; + }, }; - defer allocator.free(schwab_accounts); - - const results = try compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of); defer allocator.free(results); - try displaySchwabResults(results, color, out); - try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); + try schwab.displaySchwabResults(results, color, out); + try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); - const present = try presentNumbers(allocator, SchwabAccountComparison, results); + const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results); defer allocator.free(present); - const absent = try findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); + const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); defer allocator.free(absent); - try displayAbsentAccounts(absent, color, out); + try common.displayAbsentAccounts(absent, color, out); } // Fidelity CSV @@ -2283,26 +218,26 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { }; defer allocator.free(csv_data); - const brokerage_positions = parseFidelityCsv(allocator, csv_data) catch { - cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n"); - return; + const results = fidelity.reconcile(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n"); + return; + }, }; - defer allocator.free(brokerage_positions); - - const results = try compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices, as_of); defer { for (results) |r| allocator.free(r.comparisons); allocator.free(results); } - try displayResults(results, color, out); - try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); + try common.displayResults(results, color, out); + try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); - const present = try presentNumbers(allocator, AccountComparison, results); + const present = try common.presentNumbers(allocator, common.AccountComparison, results); defer allocator.free(present); - const absent = try findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of); + const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of); defer allocator.free(absent); - try displayAbsentAccounts(absent, color, out); + try common.displayAbsentAccounts(absent, color, out); } // Schwab per-account CSV @@ -2315,26 +250,26 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { }; defer allocator.free(csv_data); - const csv = parseSchwabCsv(allocator, csv_data) catch { - cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n"); - return; + const results = schwab.reconcileCsv(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n"); + return; + }, }; - defer allocator.free(csv.positions); - - const results = try compareAccounts(allocator, portfolio, csv.positions, account_map, "schwab", prices, as_of); defer { for (results) |r| allocator.free(r.comparisons); allocator.free(results); } - try displayResults(results, color, out); - try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); + try common.displayResults(results, color, out); + try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); - const present = try presentNumbers(allocator, AccountComparison, results); + const present = try common.presentNumbers(allocator, common.AccountComparison, results); defer allocator.free(present); - const absent = try findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); + const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); defer allocator.free(absent); - try displayAbsentAccounts(absent, color, out); + try common.displayAbsentAccounts(absent, color, out); } } @@ -2349,7 +284,7 @@ test "parseArgs: defaults" { try std.testing.expect(parsed.schwab_csv == null); try std.testing.expect(!parsed.schwab_summary); try std.testing.expect(!parsed.verbose); - try std.testing.expectEqual(default_stale_days, parsed.stale_days); + try std.testing.expectEqual(hygiene.default_stale_days, parsed.stale_days); } test "parseArgs: --fidelity captures CSV path" { @@ -2443,1268 +378,3 @@ test "parseArgs: combined valid flags parse together" { try std.testing.expect(parsed.verbose); try std.testing.expectEqual(@as(u32, 7), parsed.stale_days); } - -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 "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; - - // Empty → no output. - { - var w = std.Io.Writer.fixed(&buf); - try displayAbsentAccounts(&.{}, false, &w); - try std.testing.expectEqual(@as(usize, 0), w.buffered().len); - } - - // Non-empty → names, numbers, and a money total appear. - { - var w = std.Io.Writer.fixed(&buf); - const absent = [_]AbsentAccount{ - .{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 }, - }; - try displayAbsentAccounts(&absent, false, &w); - const out = w.buffered(); - try std.testing.expect(std.mem.indexOf(u8, out, "not found in this export") != null); - try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null); - try std.testing.expect(std.mem.indexOf(u8, out, "#1234") != null); - try std.testing.expect(std.mem.indexOf(u8, out, "$2,100") != null); - } -} - -test "detectBrokerFileKind: fidelity csv" { - const fidelity_header = "Account Number,Account Name,Symbol,Description"; - try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?); -} - -test "detectBrokerFileKind: fidelity csv with BOM" { - const fidelity_bom = "\xEF\xBB\xBFAccount Number,Account Name,Symbol"; - try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_bom).?); -} - -test "detectBrokerFileKind: schwab csv" { - const schwab_header = "\"Positions for account Roth IRA ...1234 as of\""; - try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(schwab_header).?); -} - -test "detectBrokerFileKind: schwab summary" { - const schwab_summary_data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00"; - try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(schwab_summary_data).?); -} - -test "detectBrokerFileKind: unknown file" { - const random_data = "This is just some random text that doesn't match any pattern"; - try std.testing.expect(detectBrokerFileKind(random_data) == null); -} - -test "stalenessColor: within threshold" { - try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(2, 3)); - try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(3, 3)); -} - -test "stalenessColor: warning zone (1-2x threshold)" { - try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(4, 3)); - try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(6, 3)); -} - -test "stalenessColor: critical zone (>2x threshold)" { - try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(7, 3)); - try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(30, 3)); -} - -test "UpdateCadence thresholdDays" { - try std.testing.expectEqual(@as(?u32, 7), analysis.UpdateCadence.weekly.thresholdDays()); - try std.testing.expectEqual(@as(?u32, 30), analysis.UpdateCadence.monthly.thresholdDays()); - try std.testing.expectEqual(@as(?u32, 90), analysis.UpdateCadence.quarterly.thresholdDays()); - try std.testing.expect(analysis.UpdateCadence.none.thresholdDays() == null); -} - -test "findModifiedAccounts: detects share changes" { - const allocator = std.testing.allocator; - - var old_lots = [_]portfolio_mod.Lot{ - .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, - .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, - }; - var new_lots = [_]portfolio_mod.Lot{ - .{ .symbol = "AAPL", .shares = 110, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, // shares changed - .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, // unchanged - }; - - const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; - const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; - - var modified = try findModifiedAccounts(allocator, old_pf, new_pf); - defer modified.deinit(); - - try std.testing.expect(modified.contains("Acct A")); - try std.testing.expect(!modified.contains("Acct B")); -} - -test "findModifiedAccounts: detects new lots" { - const allocator = std.testing.allocator; - - var old_lots = [_]portfolio_mod.Lot{ - .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, - }; - var new_lots = [_]portfolio_mod.Lot{ - .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, - .{ .symbol = "VTI", .shares = 200, .open_date = Date.fromYmd(2025, 3, 1), .open_price = 200.0, .account = "Acct A" }, - }; - - const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; - const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; - - var modified = try findModifiedAccounts(allocator, old_pf, new_pf); - defer modified.deinit(); - - try std.testing.expect(modified.contains("Acct A")); -} - -test "findModifiedAccounts: detects price changes" { - const allocator = std.testing.allocator; - - var old_lots = [_]portfolio_mod.Lot{ - .{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9) }, - }; - var new_lots = [_]portfolio_mod.Lot{ - .{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18) }, - }; - - const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; - const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; - - var modified = try findModifiedAccounts(allocator, old_pf, new_pf); - defer modified.deinit(); - - try std.testing.expect(modified.contains("401k")); -} - -test "findModifiedAccounts: detects removed lots" { - const allocator = std.testing.allocator; - - var old_lots = [_]portfolio_mod.Lot{ - .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, - .{ .symbol = "VTI", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Acct A" }, - }; - var new_lots = [_]portfolio_mod.Lot{ - .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, - // VTI removed - }; - - const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; - const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; - - var modified = try findModifiedAccounts(allocator, old_pf, new_pf); - defer modified.deinit(); - - try std.testing.expect(modified.contains("Acct A")); -} - -test "findModifiedAccounts: no changes" { - const allocator = std.testing.allocator; - - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, - }; - - const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - - var modified = try findModifiedAccounts(allocator, pf, pf); - defer modified.deinit(); - - try std.testing.expectEqual(@as(u32, 0), modified.count()); -} - -// ── collectStaleManualPrices ───────────────────────────────── - -test "collectStaleManualPrices: dated stale stock lot is flagged with account + age" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, - }; - const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - - var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); - defer stale.deinit(allocator); - - try std.testing.expectEqual(@as(usize, 1), stale.items.len); - try std.testing.expectEqualStrings("Sample 529", stale.items[0].account); - try std.testing.expectEqualStrings("F529A", stale.items[0].symbol); - try std.testing.expectEqual(@as(?i32, 31), stale.items[0].age_days); -} - -test "collectStaleManualPrices: price within threshold is skipped" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 30) }, - }; - const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); - defer stale.deinit(allocator); - try std.testing.expectEqual(@as(usize, 0), stale.items.len); -} - -test "collectStaleManualPrices: undated manual price is always flagged" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = null }, - }; - const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); - defer stale.deinit(allocator); - try std.testing.expectEqual(@as(usize, 1), stale.items.len); - try std.testing.expectEqual(@as(?Date, null), stale.items[0].price_date); - try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days); -} - -test "collectStaleManualPrices: CDs and cash are excluded" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2020, 1, 1) }, - .{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cash, .price = 5000.0, .price_date = Date.fromYmd(2020, 1, 1) }, - }; - const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); - defer stale.deinit(allocator); - try std.testing.expectEqual(@as(usize, 0), stale.items.len); -} - -test "collectStaleManualPrices: closed lot is excluded" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2020, 1, 1), .close_date = Date.fromYmd(2026, 1, 1), .close_price = 26.0 }, - }; - const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); - defer stale.deinit(allocator); - try std.testing.expectEqual(@as(usize, 0), stale.items.len); -} - -test "collectStaleManualPrices: lot without a manual price is skipped" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" }, - }; - const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); - defer stale.deinit(allocator); - try std.testing.expectEqual(@as(usize, 0), stale.items.len); -} - -// ── findPriceDateMismatches ────────────────────────────────── - -test "findPriceDateMismatches: price moved but date unchanged is flagged" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var head = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, - }; - var work = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) }, - }; - const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; - const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; - - var m = try findPriceDateMismatches(allocator, cp, wp, as_of); - defer m.deinit(allocator); - try std.testing.expectEqual(@as(usize, 1), m.items.len); - try std.testing.expectEqualStrings("F529A", m.items[0].symbol); - try std.testing.expectEqual(@as(f64, 25.0), m.items[0].old_price); - try std.testing.expectEqual(@as(f64, 27.5), m.items[0].new_price); -} - -test "findPriceDateMismatches: price moved AND date moved (back-date) is not flagged" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var head = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, - }; - var work = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 29) }, - }; - const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; - const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; - var m = try findPriceDateMismatches(allocator, cp, wp, as_of); - defer m.deinit(allocator); - try std.testing.expectEqual(@as(usize, 0), m.items.len); -} - -test "findPriceDateMismatches: unchanged price is not flagged" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var lots = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, - }; - const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; - var m = try findPriceDateMismatches(allocator, pf, pf, as_of); - defer m.deinit(allocator); - try std.testing.expectEqual(@as(usize, 0), m.items.len); -} - -test "findPriceDateMismatches: newly-added lot is not flagged" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var head = [_]portfolio_mod.Lot{}; - var work = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) }, - }; - const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; - const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; - var m = try findPriceDateMismatches(allocator, cp, wp, as_of); - defer m.deinit(allocator); - try std.testing.expectEqual(@as(usize, 0), m.items.len); -} - -test "findPriceDateMismatches: undated working lot is left to the stale-price section" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var head = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, - }; - var work = [_]portfolio_mod.Lot{ - .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = null }, - }; - const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; - const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; - var m = try findPriceDateMismatches(allocator, cp, wp, as_of); - defer m.deinit(allocator); - try std.testing.expectEqual(@as(usize, 0), m.items.len); -} - -test "findPriceDateMismatches: CD price change is ignored" { - const allocator = std.testing.allocator; - const as_of = Date.fromYmd(2026, 6, 1); - var head = [_]portfolio_mod.Lot{ - .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2026, 5, 1) }, - }; - var work = [_]portfolio_mod.Lot{ - .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10100.0, .price_date = Date.fromYmd(2026, 5, 1) }, - }; - const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; - const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; - var m = try findPriceDateMismatches(allocator, cp, wp, as_of); - defer m.deinit(allocator); - try std.testing.expectEqual(@as(usize, 0), m.items.len); -} - -test "hasAccountDiscrepancies" { - const clean = [_]AccountComparison{.{ - .account_name = "Acct", - .brokerage_name = "Schwab", - .account_number = "123", - .comparisons = &.{}, - .portfolio_total = 1000, - .brokerage_total = 1000, - .total_delta = 0, - .option_value_delta = 0, - .has_discrepancies = false, - }}; - try std.testing.expect(!hasAccountDiscrepancies(&clean)); - - const dirty = [_]AccountComparison{.{ - .account_name = "Acct", - .brokerage_name = "Schwab", - .account_number = "123", - .comparisons = &.{}, - .portfolio_total = 1000, - .brokerage_total = 1100, - .total_delta = 100, - .option_value_delta = 0, - .has_discrepancies = true, - }}; - try std.testing.expect(hasAccountDiscrepancies(&dirty)); -} - -test "hasSchwabDiscrepancies" { - const clean = [_]SchwabAccountComparison{.{ - .account_name = "IRA", - .schwab_name = "Roth IRA", - .account_number = "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 "detectBrokerFileKind: schwab csv with Positions header" { - const data = "\"Positions for account Brokerage ...1234 as of 11:31 AM ET, 2026/04/25\"\n\nSymbol,Description,Quantity"; - try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?); -} - -test "detectBrokerFileKind: schwab summary with Roth IRA" { - const data = "Roth IRA ...1234\nSome text\n$50,000.00\n"; - try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(data).?); -} - -test "UpdateCadence label" { - try std.testing.expectEqualStrings("weekly", analysis.UpdateCadence.weekly.label()); - try std.testing.expectEqualStrings("monthly", analysis.UpdateCadence.monthly.label()); - try std.testing.expectEqualStrings("quarterly", analysis.UpdateCadence.quarterly.label()); - try std.testing.expectEqualStrings("none", analysis.UpdateCadence.none.label()); -} - -test "discoverBrokerFiles: finds files in temp directory" { - const io = std.testing.io; - const allocator = std.testing.allocator; - - // Create a temp directory with test files - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - // Write a fidelity CSV - tmp.dir.writeFile(io, .{ - .sub_path = "fidelity.csv", - .data = "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Current Value\nZ123,Test,AAPL,Apple,100,200,20000\n", - }) catch unreachable; - - // Write a schwab summary (non-CSV) - tmp.dir.writeFile(io, .{ - .sub_path = "schwab.txt", - .data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00\n", - }) catch unreachable; - - // Write a random non-matching file - tmp.dir.writeFile(io, .{ - .sub_path = "notes.txt", - .data = "Just some random notes", - }) catch unreachable; - - // Get the temp dir path - const tmp_path = tmp.dir.realPathFileAlloc(io, ".", allocator) catch unreachable; - defer allocator.free(tmp_path); - - // wall-clock required: test writes real files and verifies they're - // treated as fresh. A fixed synthetic `now_s` would drift relative - // to the file mtime and produce flaky results. - const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); - const files = try discoverBrokerFiles(io, allocator, tmp_path, "test/", now_s); - defer { - for (files) |f| allocator.free(f.path); - allocator.free(files); - } - - // Should find fidelity CSV and schwab summary, but not notes.txt - try std.testing.expectEqual(@as(usize, 2), files.len); - - var found_fidelity = false; - var found_schwab = false; - for (files) |f| { - switch (f.kind) { - .fidelity_csv => found_fidelity = true, - .schwab_summary => found_schwab = true, - else => {}, - } - } - try std.testing.expect(found_fidelity); - try std.testing.expect(found_schwab); -} - -test "discoverBrokerFiles: empty directory returns empty" { - const io = std.testing.io; - const allocator = std.testing.allocator; - - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - const tmp_path = tmp.dir.realPathFileAlloc(io, ".", allocator) catch unreachable; - defer allocator.free(tmp_path); - - const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); - const files = try discoverBrokerFiles(io, allocator, tmp_path, "test/", now_s); - defer allocator.free(files); - - try std.testing.expectEqual(@as(usize, 0), files.len); -} - -test "discoverBrokerFiles: nonexistent directory returns empty" { - const io = std.testing.io; - const allocator = std.testing.allocator; - - const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); - const files = try discoverBrokerFiles(io, allocator, "/nonexistent/path/audit", "test/", now_s); - defer allocator.free(files); - - try std.testing.expectEqual(@as(usize, 0), files.len); -} - -test "printLargeLotWarning: cash destination emits dest_lot::cash template" { - var buf: [1024]u8 = undefined; - var writer = std.Io.Writer.fixed(&buf); - - const lot: contributions.UnmatchedLargeLot = .{ - .account = "Acct A", - .symbol = "", - .security_type = .cash, - .value = 50_000.0, - .open_date = Date.fromYmd(2026, 5, 10), - }; - - try printLargeLotWarning(&writer, lot, false); // color=false → no ANSI escapes - const output = writer.buffered(); - - // Header line with account + value + date. - try std.testing.expect(std.mem.indexOf(u8, output, "Acct A: new CASH lot cash") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "+$50,000.00") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null); - - // Template line with the expected SRF shape. - try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000.00,from::,to::Acct A,dest_lot::cash") != null); -} - -test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" { - var buf: [1024]u8 = undefined; - var writer = std.Io.Writer.fixed(&buf); - - const lot: contributions.UnmatchedLargeLot = .{ - .account = "Acct B", - .symbol = "SYM", - .security_type = .stock, - .value = 25_000.0, - .open_date = Date.fromYmd(2026, 5, 3), - }; - - try printLargeLotWarning(&writer, lot, false); - const output = writer.buffered(); - - try std.testing.expect(std.mem.indexOf(u8, output, "Acct B: new STOCK lot SYM") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "+$25,000.00") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000.00,from::,to::Acct B,dest_lot::SYM@2026-05-03") != null); -} - -test "printLargeLotWarning: cents are preserved in template" { - // Regression: previously the template rounded to whole dollars, - // so a $73,158.33 lot suggested `amount:num:73158`. Pasting that - // verbatim into transaction_log.srf records a fictitious amount - // and (with $1 matcher tolerance) only barely pairs. The fix - // prints two-decimal precision so the suggested record exactly - // describes the lot it's offering to attribute. - var buf: [1024]u8 = undefined; - var writer = std.Io.Writer.fixed(&buf); - - const lot: contributions.UnmatchedLargeLot = .{ - .account = "Sample Trust", - .symbol = "", - .security_type = .cash, - .value = 73_158.33, - .open_date = Date.fromYmd(2026, 5, 20), - }; - - try printLargeLotWarning(&writer, lot, false); - const output = writer.buffered(); - - try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158.33") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158,") == null); -} - -test "strLessThan: orders strings lexicographically" { - try std.testing.expect(strLessThan({}, "AAPL", "MSFT")); - try std.testing.expect(!strLessThan({}, "MSFT", "AAPL")); - try std.testing.expect(!strLessThan({}, "AAPL", "AAPL")); - try std.testing.expect(strLessThan({}, "AAPL", "AAPLE")); -} - -test "lotToString: stock lot includes symbol, shares, date" { - const allocator = std.testing.allocator; - const lot = portfolio_mod.Lot{ - .symbol = "AAPL", - .shares = 100, - .open_date = Date.fromYmd(2024, 3, 15), - .open_price = 150.50, - }; - const s = try lotToString(allocator, lot); - defer allocator.free(s); - try std.testing.expect(std.mem.indexOf(u8, s, "AAPL") != null); - try std.testing.expect(std.mem.indexOf(u8, s, "100") != null); - try std.testing.expect(std.mem.indexOf(u8, s, "2024-03-15") != null); -} - -test "compareSchwabSummary: matching account → no discrepancy" { - const allocator = std.testing.allocator; - const today = Date.fromYmd(2026, 5, 8); - - // Portfolio: $5000 cash + 10 AAPL @ open_price 150 = $1500 cost basis. - // With AAPL price=200, total = 5000 + 10*200 = 7000. - const lots = [_]portfolio_mod.Lot{ - .{ - .symbol = "CASH", - .shares = 5000, - .open_date = Date.fromYmd(2024, 1, 1), - .open_price = 1.0, - .security_type = .cash, - .account = "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 = [_]SchwabAccountSummary{ - .{ - .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 = [_]SchwabAccountSummary{ - .{ - .account_name = "Brokerage", - .account_number = "1234", - .cash = 5500.0, - .total_value = 5500.0, - }, - }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ - .account = "Brokerage", - .tax_type = .taxable, - .institution = "schwab", - .account_number = "1234", - }, - }; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - - const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today); - defer allocator.free(results); - - try std.testing.expectEqual(@as(usize, 1), results.len); - try std.testing.expectApproxEqAbs(@as(f64, 500), results[0].cash_delta.?, 0.01); - try std.testing.expect(results[0].has_discrepancy); -} - -test "compareSchwabSummary: 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 = [_]SchwabAccountSummary{ - .{ .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 = [_]SchwabAccountSummary{ - .{ - .account_name = "Unknown Acct", - .account_number = "9999", - .cash = 1000.0, - .total_value = 1000.0, - }, - }; - - var entries = [_]analysis.AccountTaxEntry{}; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - - const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today); - defer allocator.free(results); - - try std.testing.expectEqual(@as(usize, 1), results.len); - try std.testing.expectEqualStrings("", results[0].account_name); - try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name); - // No portfolio match → cash and total are zero, schwab values become deltas - try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01); - try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01); -} - -test "compareSchwabSummary: null cash/total fields produce null deltas (within tolerance)" { - const allocator = std.testing.allocator; - const today = Date.fromYmd(2026, 5, 8); - - const lots = [_]portfolio_mod.Lot{ - .{ - .symbol = "CASH", - .shares = 5000, - .open_date = Date.fromYmd(2024, 1, 1), - .open_price = 1.0, - .security_type = .cash, - .account = "X", - }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; - - // Schwab summary missing cash + total fields (.cash = null, .total_value = null). - const schwab_accounts = [_]SchwabAccountSummary{ - .{ - .account_name = "X", - .account_number = "1234", - .cash = null, - .total_value = null, - }, - }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ - .account = "X", - .tax_type = .taxable, - .institution = "schwab", - .account_number = "1234", - }, - }; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - - const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today); - defer allocator.free(results); - - try std.testing.expectEqual(@as(usize, 1), results.len); - try std.testing.expect(results[0].cash_delta == null); - try std.testing.expect(results[0].total_delta == null); - // Null deltas are treated as "ok" (no discrepancy possible to assert). - try std.testing.expect(!results[0].has_discrepancy); -} - -test "compareSchwabSummary: today affects valuation of held assets" { - const allocator = std.testing.allocator; - - // Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before - // open), it's not held → portfolio_total excludes it. With - // today=2025-01-01 (after open), portfolio_total includes 10 * price. - const lots = [_]portfolio_mod.Lot{ - .{ - .symbol = "AAPL", - .shares = 10, - .open_date = Date.fromYmd(2024, 6, 1), - .open_price = 150, - .account = "Acct", - }, - }; - const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; - - const schwab_accounts = [_]SchwabAccountSummary{ - .{ - .account_name = "Acct", - .account_number = "1234", - .cash = 0, - .total_value = 2000, - }, - }; - - var entries = [_]analysis.AccountTaxEntry{ - .{ - .account = "Acct", - .tax_type = .taxable, - .institution = "schwab", - .account_number = "1234", - }, - }; - const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - try prices.put("AAPL", 200.0); - - // Before open: portfolio holds nothing for this account. - { - const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2024, 1, 1)); - defer allocator.free(results); - try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_total, 0.01); - } - - // After open: portfolio holds 10 * 200 = 2000. - { - const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1)); - defer allocator.free(results); - try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01); - // Matches schwab → no discrepancy. - try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01); - try std.testing.expect(!results[0].has_discrepancy); - } -} diff --git a/src/commands/audit/common.zig b/src/commands/audit/common.zig new file mode 100644 index 0000000..be0a9ce --- /dev/null +++ b/src/commands/audit/common.zig @@ -0,0 +1,1672 @@ +//! Shared reconciliation engine for the `audit` command. +//! +//! 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`. + +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 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; + +/// 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, + only_in_brokerage: bool, + only_in_portfolio: bool, +}; + +/// Comparison result for a single account. +pub const AccountComparison = struct { + account_name: []const u8, + brokerage_name: []const u8, + account_number: []const u8, + comparisons: []const SymbolComparison, + portfolio_total: f64, + brokerage_total: f64, + total_delta: f64, + option_value_delta: f64, + has_discrepancies: bool, +}; + +/// Consolidate broker rows that share a symbol within the same +/// account into a single position. Some brokers split a single +/// stock holding into separate "Cash" and "Margin" rows for the +/// same ticker in the same account — Fidelity does this when a +/// freshly-credited lot (e.g. an RSU distribution) hasn't yet +/// cleared settlement (T+1 / T+2) and is therefore considered +/// un-marginable, while the older settled shares stay in the +/// margin sub-account. Without consolidation, the audit would +/// double-count when matching against the portfolio's +/// account-level aggregate. +/// +/// Aggregation rules: +/// - `quantity` and `current_value` are summed across rows +/// (treating null as 0 for the sum, but preserving null when +/// no row supplied a value). +/// - `cost_basis` is summed the same way. +/// - `is_cash` is OR-ed across rows: any cash row in the group +/// marks the consolidated entry as cash. In practice a single +/// symbol is either always-cash (money market) or never (stock), +/// so this is just defensive. +/// - `account_number`, `account_name`, `description` are taken +/// from the first row in the group. +/// +/// Caller owns the returned ArrayList. +fn consolidateBySymbol( + allocator: std.mem.Allocator, + rows: []const BrokeragePosition, +) !std.ArrayList(BrokeragePosition) { + var by_symbol = std.StringHashMap(usize).init(allocator); + defer by_symbol.deinit(); + + var out: std.ArrayList(BrokeragePosition) = .empty; + errdefer out.deinit(allocator); + + for (rows) |bp| { + if (by_symbol.get(bp.symbol)) |idx| { + const existing = &out.items[idx]; + // Sum quantity (null + value = value; null + null = null). + existing.quantity = sumOptional(existing.quantity, bp.quantity); + existing.current_value = sumOptional(existing.current_value, bp.current_value); + existing.cost_basis = sumOptional(existing.cost_basis, bp.cost_basis); + existing.is_cash = existing.is_cash or bp.is_cash; + } else { + try by_symbol.put(bp.symbol, out.items.len); + try out.append(allocator, bp); + } + } + + return out; +} + +fn sumOptional(a: ?f64, b: ?f64) ?f64 { + if (a == null and b == null) return null; + return (a orelse 0) + (b orelse 0); +} + +/// Build per-account comparisons between portfolio.srf and brokerage data. +pub fn compareAccounts( + allocator: std.mem.Allocator, + portfolio: zfin.Portfolio, + brokerage_positions: []const BrokeragePosition, + account_map: analysis.AccountMap, + institution: []const u8, + prices: std.StringHashMap(f64), + as_of: Date, +) ![]AccountComparison { + var results = std.ArrayList(AccountComparison).empty; + errdefer results.deinit(allocator); + + // Group brokerage positions by account number + var brokerage_accounts = std.StringHashMap(std.ArrayList(BrokeragePosition)).init(allocator); + defer { + var it = brokerage_accounts.valueIterator(); + while (it.next()) |v| v.deinit(allocator); + brokerage_accounts.deinit(); + } + + for (brokerage_positions) |bp| { + const entry = try brokerage_accounts.getOrPut(bp.account_number); + if (!entry.found_existing) { + entry.value_ptr.* = .empty; + } + try entry.value_ptr.append(allocator, bp); + } + + // Aggregate same-symbol rows within each account. Some brokers + // report a single security as multiple rows when a position + // straddles sub-account contexts. The motivating case is + // Fidelity's margin-eligible accounts: when a freshly-credited + // lot (e.g. an RSU distribution) hasn't yet cleared settlement + // (T+1 / T+2), Fidelity classifies the new shares as + // un-marginable "Cash" and the older settled shares as + // "Margin", reporting them as two CSV rows for the same + // ticker in the same account number. Once settlement clears, + // the rows usually consolidate back into one — but until + // then, the audit needs to consolidate them itself, otherwise + // it'd match each broker row independently against the + // (already-aggregated) portfolio total and report a phantom + // discrepancy on every duplicate. Aggregating here lets the + // rest of the comparator stay (account, symbol)-keyed + // regardless of how the broker chose to slice the rows. + var consolidated_accounts = std.StringHashMap(std.ArrayList(BrokeragePosition)).init(allocator); + defer { + var it = consolidated_accounts.valueIterator(); + while (it.next()) |v| v.deinit(allocator); + consolidated_accounts.deinit(); + } + { + var acct_it = brokerage_accounts.iterator(); + while (acct_it.next()) |kv| { + const consolidated = try consolidateBySymbol(allocator, kv.value_ptr.items); + try consolidated_accounts.put(kv.key_ptr.*, consolidated); + } + } + + // For each brokerage account, find the matching portfolio account and compare + var acct_iter = consolidated_accounts.iterator(); + while (acct_iter.next()) |kv| { + const acct_num = kv.key_ptr.*; + const broker_positions = kv.value_ptr.items; + if (broker_positions.len == 0) continue; + + const broker_name = broker_positions[0].account_name; + const portfolio_acct_name = account_map.findByInstitutionAccount(institution, acct_num); + + var comparisons = std.ArrayList(SymbolComparison).empty; + errdefer comparisons.deinit(allocator); + + var portfolio_total: f64 = 0; + var brokerage_total: f64 = 0; + var option_value_delta: f64 = 0; + var has_discrepancies = false; + + // Track which portfolio symbols we've matched + var matched_symbols = std.StringHashMap(void).init(allocator); + defer matched_symbols.deinit(); + + // Compare each brokerage position against portfolio + for (broker_positions) |bp| { + const bp_value = bp.current_value orelse 0; + brokerage_total += bp_value; + + if (portfolio_acct_name == null) { + const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null; + try comparisons.append(allocator, .{ + .symbol = bp.symbol, + .portfolio_shares = 0, + .brokerage_shares = bp.quantity, + .portfolio_price = null, + .brokerage_price = br_price, + .portfolio_value = 0, + .brokerage_value = bp.current_value, + .shares_delta = if (bp.quantity) |q| q else null, + .value_delta = bp.current_value, + .is_cash = bp.is_cash, + .is_option = false, + .only_in_brokerage = true, + .only_in_portfolio = false, + }); + has_discrepancies = true; + continue; + } + + // Sum portfolio lots for this symbol+account + var pf_shares: f64 = 0; + var pf_value: f64 = 0; + var pf_price: ?f64 = null; + var is_option = false; + + if (bp.is_cash) { + pf_shares = portfolio.cashForAccount(portfolio_acct_name.?); + pf_value = pf_shares; + } else { + const acct_positions = portfolio.positionsForAccount(as_of, allocator, portfolio_acct_name.?) catch &.{}; + defer allocator.free(acct_positions); + + var found_stock = false; + for (acct_positions) |pos| { + if (!std.mem.eql(u8, pos.symbol, bp.symbol) and + !std.mem.eql(u8, pos.lot_symbol, bp.symbol)) + continue; + pf_shares = pos.shares; + const v = resolvePositionValue(pos, prices); + pf_price = v.price; + pf_value = v.value; + try matched_symbols.put(pos.symbol, {}); + try matched_symbols.put(pos.lot_symbol, {}); + found_stock = true; + } + + if (!found_stock) { + for (portfolio.lots) |lot| { + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, portfolio_acct_name.?)) continue; + if (!lot.isOpen(as_of)) continue; + // Match by exact symbol, or by parsed option components + // (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; + }, + .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, + .only_in_brokerage = pf_shares == 0 and pf_value == 0, + .only_in_portfolio = false, + }); + } + + // Find portfolio-only positions (in portfolio but not in brokerage) + if (portfolio_acct_name) |pa| { + const acct_positions = portfolio.positionsForAccount(as_of, allocator, pa) catch &.{}; + defer allocator.free(acct_positions); + + for (acct_positions) |pos| { + if (matched_symbols.contains(pos.symbol)) continue; + if (matched_symbols.contains(pos.lot_symbol)) continue; + + try matched_symbols.put(pos.symbol, {}); + + const v = resolvePositionValue(pos, prices); + const mv = v.value; + portfolio_total += mv; + + has_discrepancies = true; + try comparisons.append(allocator, .{ + .symbol = pos.symbol, + .portfolio_shares = pos.shares, + .brokerage_shares = null, + .portfolio_price = v.price, + .brokerage_price = null, + .portfolio_value = mv, + .brokerage_value = null, + .shares_delta = null, + .value_delta = null, + .is_cash = false, + .is_option = false, + .only_in_brokerage = false, + .only_in_portfolio = true, + }); + } + + // Portfolio-only CDs and options + for (portfolio.lots) |lot| { + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, pa)) continue; + if (!lot.isOpen(as_of)) continue; + if (lot.security_type != .cd and lot.security_type != .option) continue; + if (matched_symbols.contains(lot.symbol)) continue; + + try matched_symbols.put(lot.symbol, {}); + + var pf_shares: f64 = 0; + var pf_value: f64 = 0; + var pf_price: ?f64 = null; + var is_cd = false; + + // Aggregate all lots with same symbol in this account + for (portfolio.lots) |lot2| { + const la2 = lot2.account orelse continue; + if (!std.mem.eql(u8, la2, pa)) continue; + if (!lot2.isOpen(as_of)) continue; + if (!std.mem.eql(u8, lot2.symbol, lot.symbol)) continue; + switch (lot2.security_type) { + .cd => { + pf_shares += lot2.shares; + pf_value += lot2.shares; + pf_price = 1.0; + is_cd = true; + }, + .option => { + pf_shares += lot2.shares; + pf_value += @abs(lot2.shares) * lot2.open_price * lot2.multiplier; + pf_price = lot2.open_price * lot2.multiplier; + }, + else => {}, + } + } + + if (pf_value != 0 or pf_shares != 0) { + portfolio_total += pf_value; + has_discrepancies = true; + try comparisons.append(allocator, .{ + .symbol = lot.symbol, + .portfolio_shares = pf_shares, + .brokerage_shares = null, + .portfolio_price = pf_price, + .brokerage_price = null, + .portfolio_value = pf_value, + .brokerage_value = null, + .shares_delta = null, + .value_delta = null, + .is_cash = is_cd, + .is_option = !is_cd, + .only_in_brokerage = false, + .only_in_portfolio = true, + }); + } + } + } + + try results.append(allocator, .{ + .account_name = portfolio_acct_name orelse "", + .brokerage_name = broker_name, + .account_number = acct_num, + .comparisons = try comparisons.toOwnedSlice(allocator), + .portfolio_total = portfolio_total, + .brokerage_total = brokerage_total, + .total_delta = brokerage_total - portfolio_total, + .option_value_delta = option_value_delta, + .has_discrepancies = has_discrepancies, + }); + } + + return results.toOwnedSlice(allocator); +} + +// ── Ratio suggestions ──────────────────────────────────────── + +/// After displaying audit results, check for price_ratio positions where +/// the brokerage NAV implies a different ratio than what's configured. +/// Outputs actionable suggestions for portfolio.srf updates. +/// +/// Normally only lots with `price_ratio != 1.0` get suggestions — +/// the typical case is an institutional share class where the +/// configured ratio needs to drift toward current retail-vs- +/// institutional NAV. Lots with `price_ratio == 1.0` usually have +/// nothing to adjust. +/// +/// Exception: accounts flagged `direct_indexing::true` in +/// `accounts.srf`. These are proxy baskets whose tracking-error +/// drift is expressed by periodically nudging the ratio even +/// though the starting ratio is 1.0. For those accounts we still +/// emit a suggestion when brokerage and portfolio values disagree +/// — the suggested ratio is just `brokerage_NAV / retail_price` +/// applied against the existing lot share count, same formula as +/// the institutional-class case. +pub fn displayRatioSuggestions( + results: []const AccountComparison, + portfolio: zfin.Portfolio, + prices: std.StringHashMap(f64), + account_map: ?analysis.AccountMap, + color: bool, + out: *std.Io.Writer, +) !void { + var has_header = false; + + for (results) |acct| { + for (acct.comparisons) |cmp| { + // Skip unmatched, cash, and option rows + if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue; + if (cmp.is_cash or cmp.is_option) continue; + + // Is this account flagged direct-indexing? Captured once + // per outer loop so the per-lot gate can skip the + // ratio == 1.0 check for flagged accounts. + const is_direct_indexing = if (account_map) |am| + am.isDirectIndexing(acct.account_name) + else + false; + + // Find the portfolio lot(s) for this symbol with price_ratio != 1.0 + // (or any ratio, for direct-indexing accounts). + for (portfolio.lots) |lot| { + if (lot.price_ratio == 1.0 and !is_direct_indexing) continue; + if (lot.security_type != .stock) continue; + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, acct.account_name)) continue; + + // Match by lot_symbol (CUSIP) or ticker against brokerage symbol + const lot_sym = lot.symbol; + const price_sym = lot.priceSymbol(); + if (!std.mem.eql(u8, lot_sym, cmp.symbol) and + !std.mem.eql(u8, price_sym, cmp.symbol)) continue; + + // Get the retail price from cache + const retail_price = prices.get(price_sym) orelse continue; + // Brokerage price is the institutional NAV per share + const inst_nav = cmp.brokerage_price orelse continue; + if (retail_price == 0) continue; + + const current_ratio = lot.price_ratio; + const suggested_ratio = inst_nav / retail_price; + const drift_pct = (suggested_ratio - current_ratio) / current_ratio * 100.0; + + // Only suggest if drift is meaningful (> 0.01%) + if (current_ratio == suggested_ratio) break; + + if (!has_header) { + try out.print("\n", .{}); + try cli.printBold(out, color, " Ratio updates", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf)\n", .{}); + has_header = true; + } + + var cur_buf: [24]u8 = undefined; + var sug_buf: [24]u8 = undefined; + var drift_buf: [16]u8 = undefined; + const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{current_ratio}) catch "?"; + const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?"; + const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.2}%", .{drift_pct}) catch "?"; + + try out.print(" {s:<16} ", .{lot_sym}); + try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym}); + try out.print(" ratio {s} -> ", .{cur_str}); + try cli.printBold(out, color, "{s}", .{sug_str}); + try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str}); + + break; // One suggestion per symbol + } + } + } + + if (has_header) try out.print("\n", .{}); +} + +// ── Display ───────────────────────────────────────────────── + +pub fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void { + try cli.printBold(out, color, "\nPortfolio Audit", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{}); + try out.print("========================================\n\n", .{}); + + var total_portfolio: f64 = 0; + var total_brokerage: f64 = 0; + var total_option_delta: f64 = 0; + var discrepancy_count: usize = 0; + + for (results) |acct| { + // Account header + if (acct.account_name.len > 0) { + try cli.printBold(out, color, " {s}", .{acct.account_name}); + try cli.printFg(out, color, cli.CLR_MUTED, " ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number }); + } else { + try cli.printBold(out, color, " {s} #{s}", .{ acct.brokerage_name, acct.account_number }); + try cli.printFg(out, color, cli.CLR_WARNING, " (unmapped — add account_number to accounts.srf)\n", .{}); + } + + // Column headers + try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {s:>10} {s:>10} {s}\n", .{ + "Symbol", "PF Shares", "BR Shares", "PF Price", "BR Price", "", + }); + + for (acct.comparisons) |cmp| { + var pf_shares_buf: [16]u8 = undefined; + var br_shares_buf: [16]u8 = undefined; + var pf_price_buf: [16]u8 = undefined; + var br_price_buf: [16]u8 = undefined; + + // Classify this row + const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage; + const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= cash_tolerance else false); + const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch; + + if (is_real_mismatch) discrepancy_count += 1; + + // Format share strings + const pf_shares_str: []const u8 = if (cmp.is_cash) + (std.fmt.bufPrint(&pf_shares_buf, "{f}", .{Money.from(cmp.portfolio_value)}) catch "$?") + else + std.fmt.bufPrint(&pf_shares_buf, "{d:.3}", .{cmp.portfolio_shares}) catch "?"; + + const br_shares_str: []const u8 = if (cmp.is_cash) + (if (cmp.brokerage_value) |v| (std.fmt.bufPrint(&br_shares_buf, "{f}", .{Money.from(v)}) catch "$?") else "--") + else if (cmp.brokerage_shares) |s| + (std.fmt.bufPrint(&br_shares_buf, "{d:.3}", .{s}) catch "?") + else + "--"; + + const pf_price_str: []const u8 = if (cmp.is_cash) + "" + else if (cmp.portfolio_price) |p| + (std.fmt.bufPrint(&pf_price_buf, "{d:.2}", .{p}) catch "?") + else + "--"; + + const br_price_str: []const u8 = if (cmp.is_cash) + "" + else if (cmp.brokerage_price) |p| + (std.fmt.bufPrint(&br_price_buf, "{d:.2}", .{p}) catch "?") + else + "--"; + + // Determine PF Shares color (relative to brokerage) + const shares_color: enum { normal, positive, negative, warning } = blk: { + if (cmp.only_in_portfolio) break :blk .warning; + if (cmp.is_cash and is_cash_mismatch) { + break :blk if (cmp.value_delta.? > 0) .negative else .positive; + } + if (shares_ok) break :blk .normal; + if (cmp.shares_delta) |d| { + break :blk if (d > 0) .negative else .positive; + } + break :blk .normal; + }; + + // Determine status text + var status_buf: [64]u8 = undefined; + const status: []const u8 = blk: { + if (cmp.only_in_brokerage) break :blk "Brokerage only"; + if (cmp.only_in_portfolio) break :blk "Portfolio only"; + + if (is_cash_mismatch) { + if (cmp.value_delta) |d| { + const sign: []const u8 = if (d >= 0) "+" else "-"; + break :blk std.fmt.bufPrint(&status_buf, "Cash {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "Cash mismatch"; + } + } + + if (!shares_ok) { + if (cmp.shares_delta) |d| { + const sign: []const u8 = if (d > 0) "+" else ""; + break :blk std.fmt.bufPrint(&status_buf, "Shares {s}{d:.3}", .{ sign, d }) catch "Shares mismatch"; + } + } + + // Options: shares match is sufficient — value delta is expected + // (cost basis vs mark-to-market) and not actionable + if (cmp.is_option) break :blk "Option"; + + // Shares match — show value delta (stale price) if any, muted + if (cmp.value_delta) |d| { + if (@abs(d) >= value_tolerance) { + const sign: []const u8 = if (d >= 0) "+" else "-"; + break :blk std.fmt.bufPrint(&status_buf, "Value {s}{f}", .{ sign, Money.from(@abs(d)) }) catch ""; + } + } + + break :blk ""; + }; + + // Status color: real mismatches in warning, stale values muted, ok is blank + const status_color: enum { warning, muted, none } = blk: { + if (is_real_mismatch) break :blk .warning; + if (status.len > 0) break :blk .muted; + break :blk .none; + }; + + // Print symbol + try out.print(" {s:<24} ", .{cmp.symbol}); + + // Print PF Shares with color + switch (shares_color) { + .positive => try cli.printFg(out, color, cli.CLR_POSITIVE, "{s:>12}", .{pf_shares_str}), + .negative => try cli.printFg(out, color, cli.CLR_NEGATIVE, "{s:>12}", .{pf_shares_str}), + .warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s:>12}", .{pf_shares_str}), + .normal => try out.print("{s:>12}", .{pf_shares_str}), + } + + // Print BR Shares, prices + try out.print(" {s:>12} {s:>10} {s:>10} ", .{ br_shares_str, pf_price_str, br_price_str }); + + // Print status + switch (status_color) { + .warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s}\n", .{status}), + .muted => try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{status}), + .none => try out.print("{s}\n", .{status}), + } + } + + // Account totals + try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {f} {f} ", .{ + "", "", "", Money.from(acct.portfolio_total).padRight(10), Money.from(acct.brokerage_total).padRight(10), + }); + + const adj_delta = acct.total_delta - acct.option_value_delta; + if (@abs(adj_delta) < 1.0) { + // no delta text needed + } else { + const sign: []const u8 = if (adj_delta >= 0) "+" else "-"; + const rgb = if (adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; + try cli.printFg(out, color, rgb, "Delta {s}{f}", .{ sign, Money.from(@abs(adj_delta)) }); + } + + if (@abs(acct.option_value_delta) >= 1.0) { + const opt_sign: []const u8 = if (acct.option_value_delta >= 0) "+" else "-"; + try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) }); + } + try out.print("\n\n", .{}); + + total_portfolio += acct.portfolio_total; + total_brokerage += acct.brokerage_total; + total_option_delta += acct.option_value_delta; + } + + // Grand totals + const grand_delta = total_brokerage - total_portfolio; + const grand_adj_delta = grand_delta - total_option_delta; + + try cli.printBold(out, color, " Total: portfolio {f} brokerage {f}", .{ + Money.from(total_portfolio), + Money.from(total_brokerage), + }); + + if (@abs(grand_adj_delta) < 1.0) { + // no delta text needed + } else { + const sign: []const u8 = if (grand_adj_delta >= 0) "+" else "-"; + const rgb = if (grand_adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; + try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_adj_delta)) }); + } + + if (@abs(total_option_delta) >= 1.0) { + const opt_sign: []const u8 = if (total_option_delta >= 0) "+" else "-"; + try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(total_option_delta)) }); + } + try out.print("\n", .{}); + + if (discrepancy_count > 0) { + try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} to investigate\n", .{ discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches") }); + } + try out.print("\n", .{}); +} + +/// 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 this export" advisory. +/// Silent when `absent` is empty, so it composes cleanly after both +/// the verbose audit table and the compact "no discrepancies" line. +pub fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, out: *std.Io.Writer) !void { + if (absent.len == 0) return; + + try out.print("\n", .{}); + try cli.printFg(out, color, cli.CLR_WARNING, " Portfolio accounts not found in this export", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " (stale, or not exported?)\n", .{}); + for (absent) |a| { + try out.print(" ", .{}); + try cli.printFg(out, color, cli.CLR_WARNING, "{s}", .{a.account_name}); + try cli.printFg(out, color, cli.CLR_MUTED, " (#{s})", .{a.account_number}); + try out.print(" {f}\n", .{Money.from(a.portfolio_total)}); + } + 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); +} + +// ── displayResults rendering ───────────────────────────────── + +test "displayResults: renders every row classification and the totals block" { + const allocator = std.testing.allocator; + _ = allocator; + + // One mapped account exercising each status branch, plus one + // unmapped account exercising the unmapped header + only_in_* rows. + const ira_cmps = [_]SymbolComparison{ + // matched, clean → blank status, normal color + .{ .symbol = "AAPL", .portfolio_shares = 10, .brokerage_shares = 10, .portfolio_price = 200, .brokerage_price = 200, .portfolio_value = 2000, .brokerage_value = 2000, .shares_delta = 0, .value_delta = 0, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, + // share mismatch → "Shares +2.000" + .{ .symbol = "MSFT", .portfolio_shares = 5, .brokerage_shares = 7, .portfolio_price = 300, .brokerage_price = 300, .portfolio_value = 1500, .brokerage_value = 2100, .shares_delta = 2, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, + // share mismatch, broker shows FEWER shares → negative delta, "positive" color arm + .{ .symbol = "NVDA", .portfolio_shares = 10, .brokerage_shares = 8, .portfolio_price = 120, .brokerage_price = 120, .portfolio_value = 1200, .brokerage_value = 960, .shares_delta = -2, .value_delta = -240, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, + // cash mismatch → "Cash +$0.25" + .{ .symbol = "FDRXX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 38.75, .brokerage_value = 39.00, .shares_delta = null, .value_delta = 0.25, .is_cash = true, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, + // option → "Option" + .{ .symbol = "AMZN C", .portfolio_shares = -2, .brokerage_shares = -2, .portfolio_price = 875, .brokerage_price = 1625, .portfolio_value = 1750, .brokerage_value = 3250, .shares_delta = 0, .value_delta = 1500, .is_cash = false, .is_option = true, .only_in_brokerage = false, .only_in_portfolio = false }, + // shares match, stale value → "Value +$50.00" (muted) + .{ .symbol = "QTUM", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 116.0, .brokerage_price = 116.5, .portfolio_value = 11600, .brokerage_value = 11650, .shares_delta = 0, .value_delta = 50, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, + }; + const unmapped_cmps = [_]SymbolComparison{ + .{ .symbol = "TSLA", .portfolio_shares = 0, .brokerage_shares = 3, .portfolio_price = null, .brokerage_price = 200, .portfolio_value = 0, .brokerage_value = 600, .shares_delta = 3, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = true, .only_in_portfolio = false }, + .{ .symbol = "GOOG", .portfolio_shares = 4, .brokerage_shares = null, .portfolio_price = 200, .brokerage_price = null, .portfolio_value = 800, .brokerage_value = null, .shares_delta = null, .value_delta = null, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = true }, + }; + const results = [_]AccountComparison{ + .{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &ira_cmps, .portfolio_total = 16928.75, .brokerage_total = 17479.0, .total_delta = 550.25, .option_value_delta = 1500, .has_discrepancies = true }, + .{ .account_name = "", .brokerage_name = "FIDELITY", .account_number = "9999", .comparisons = &unmapped_cmps, .portfolio_total = 800, .brokerage_total = 600, .total_delta = -200, .option_value_delta = 0, .has_discrepancies = true }, + }; + + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displayResults(&results, false, &w); + const out = w.buffered(); + + // Header + account identity + try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "SCHWAB") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "1234") != null); + // Unmapped-account header branch + try std.testing.expect(std.mem.indexOf(u8, out, "unmapped") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "FIDELITY") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "9999") != null); + // Per-row status strings + try std.testing.expect(std.mem.indexOf(u8, out, "Shares +2.000") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Shares -2.000") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Cash +") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Option") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Brokerage only") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio only") != null); + // Totals block + try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "options") != null); + // 5 real mismatches (2 share, cash, brokerage-only, portfolio-only) → plural + try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null); +} + +test "displayResults: color=true emits ANSI and singular mismatch label" { + const cmps = [_]SymbolComparison{ + .{ .symbol = "GOOG", .portfolio_shares = 4, .brokerage_shares = null, .portfolio_price = 200, .brokerage_price = null, .portfolio_value = 800, .brokerage_value = null, .shares_delta = null, .value_delta = null, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = true }, + }; + const results = [_]AccountComparison{ + .{ .account_name = "Sample Roth", .brokerage_name = "SCHWAB", .account_number = "5678", .comparisons = &cmps, .portfolio_total = 800, .brokerage_total = 0, .total_delta = -800, .option_value_delta = 0, .has_discrepancies = true }, + }; + + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displayResults(&results, true, &w); + const out = w.buffered(); + + try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); // ANSI present + // exactly one real mismatch → singular label + try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null); +} + +// ── displayRatioSuggestions ────────────────────────────────── + +test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" { + const allocator = std.testing.allocator; + + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "VTHRX", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 30, .account = "Sample IRA", .price_ratio = 5.0 }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("VTHRX", 5.0); // retail close + + const cmps = [_]SymbolComparison{ + // brokerage_price (inst NAV) 30 / retail 5 = suggested ratio 6, vs configured 5 + .{ .symbol = "VTHRX", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 25.0, .brokerage_price = 30.0, .portfolio_value = 2500, .brokerage_value = 3000, .shares_delta = 0, .value_delta = 500, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, + }; + const results = [_]AccountComparison{ + .{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 2500, .brokerage_total = 3000, .total_delta = 500, .option_value_delta = 0, .has_discrepancies = true }, + }; + + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displayRatioSuggestions(&results, portfolio, prices, null, false, &w); + const out = w.buffered(); + + try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "VTHRX") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "ratio 5 -> ") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "6") != null); // suggested ratio +} + +test "displayRatioSuggestions: direct-indexing account suggests even at ratio 1.0" { + const allocator = std.testing.allocator; + + // price_ratio == 1.0 would normally be skipped, but the account is + // flagged direct_indexing → the ratio==1.0 skip is bypassed. + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "5678", .direct_indexing = true }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SPY", 500.0); + + const cmps = [_]SymbolComparison{ + .{ .symbol = "SPY", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 500, .brokerage_price = 510.0, .portfolio_value = 50000, .brokerage_value = 51000, .shares_delta = 0, .value_delta = 1000, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, + }; + const results = [_]AccountComparison{ + .{ .account_name = "Sample Brokerage", .brokerage_name = "SCHWAB", .account_number = "5678", .comparisons = &cmps, .portfolio_total = 50000, .brokerage_total = 51000, .total_delta = 1000, .option_value_delta = 0, .has_discrepancies = true }, + }; + + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displayRatioSuggestions(&results, portfolio, prices, acct_map, false, &w); + const out = w.buffered(); + + // suggested = 510/500 = 1.02, configured 1.0 → drift suggestion emitted + try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "SPY") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "ratio 1 -> ") != null); +} + +test "displayRatioSuggestions: cash/option/only rows produce no output" { + const allocator = std.testing.allocator; + + const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const cmps = [_]SymbolComparison{ + .{ .symbol = "FDRXX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 100, .brokerage_value = 100, .shares_delta = null, .value_delta = 0, .is_cash = true, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, + .{ .symbol = "AMZN C", .portfolio_shares = -2, .brokerage_shares = -2, .portfolio_price = 875, .brokerage_price = 1625, .portfolio_value = 1750, .brokerage_value = 3250, .shares_delta = 0, .value_delta = 1500, .is_cash = false, .is_option = true, .only_in_brokerage = false, .only_in_portfolio = false }, + .{ .symbol = "TSLA", .portfolio_shares = 0, .brokerage_shares = 3, .portfolio_price = null, .brokerage_price = 200, .portfolio_value = 0, .brokerage_value = 600, .shares_delta = 3, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = true, .only_in_portfolio = false }, + }; + const results = [_]AccountComparison{ + .{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 1850, .brokerage_total = 3950, .total_delta = 2100, .option_value_delta = 1500, .has_discrepancies = true }, + }; + + var buf: [1024]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displayRatioSuggestions(&results, portfolio, prices, null, false, &w); + // 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; + + // Empty → no output. + { + var w = std.Io.Writer.fixed(&buf); + try displayAbsentAccounts(&.{}, false, &w); + try std.testing.expectEqual(@as(usize, 0), w.buffered().len); + } + + // Non-empty → names, numbers, and a money total appear. + { + var w = std.Io.Writer.fixed(&buf); + const absent = [_]AbsentAccount{ + .{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 }, + }; + try displayAbsentAccounts(&absent, false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "not found in this export") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "#1234") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "$2,100") != null); + } +} diff --git a/src/commands/audit/fidelity.zig b/src/commands/audit/fidelity.zig new file mode 100644 index 0000000..cf47d64 --- /dev/null +++ b/src/commands/audit/fidelity.zig @@ -0,0 +1,91 @@ +//! Fidelity reconciler for the `audit` command. +//! +//! Fidelity exports a single "all accounts" positions CSV. Parsing +//! lives in `brokerage/fidelity.zig`; this module wires that parser +//! into the shared per-account comparison engine in `common.zig`. +//! Because the Fidelity export is a plain per-account positions list, +//! the only Fidelity-specific knowledge here is "use the Fidelity CSV +//! parser" and "the institution key is `fidelity`" — everything else +//! (comparison, display) is shared. + +const std = @import("std"); +const zfin = @import("../../root.zig"); +const analysis = @import("../../analytics/analysis.zig"); +const Date = @import("../../Date.zig"); +const common = @import("common.zig"); +const fidelity_parser = @import("../../brokerage/fidelity.zig"); + +/// Parse a Fidelity positions CSV and reconcile it against the +/// portfolio. Returns owned `AccountComparison` results (free each +/// `.comparisons` slice, then the results slice). String fields in +/// the results borrow from `csv_data`, which must outlive them. +/// +/// Propagates the parser's errors (`EmptyFile` / `UnexpectedHeader`) +/// and allocation failures so the caller can decide between a +/// user-facing message (explicit `--fidelity`) and silently skipping +/// the file (flagless auto-reconcile). +pub fn reconcile( + allocator: std.mem.Allocator, + portfolio: zfin.Portfolio, + csv_data: []const u8, + account_map: analysis.AccountMap, + prices: std.StringHashMap(f64), + as_of: Date, +) ![]common.AccountComparison { + const positions = try fidelity_parser.parseCsv(allocator, csv_data); + // The result strings borrow from `csv_data`, not from this slice, + // so freeing the slice array here is safe. + defer allocator.free(positions); + return common.compareAccounts(allocator, portfolio, positions, account_map, "fidelity", prices, as_of); +} + +// ── Tests ──────────────────────────────────────────────────── + +const portfolio_mod = @import("../../models/portfolio.zig"); + +test "reconcile: parses Fidelity CSV and matches against portfolio" { + const allocator = std.testing.allocator; + + const csv = + "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++ + "Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,100%,$10000.00,$100.00,Margin,\n"; + + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100.0, .account = "Sample Brokerage" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("AAPL", 150.0); + + const results = try reconcile(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 5, 8)); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name); + // 100 shares @ $150 on both sides → no discrepancy. + try std.testing.expect(!results[0].has_discrepancies); +} + +test "reconcile: propagates parser errors" { + const allocator = std.testing.allocator; + const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator }; + var entries = [_]analysis.AccountTaxEntry{}; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + try std.testing.expectError( + error.EmptyFile, + reconcile(allocator, portfolio, "", acct_map, prices, Date.fromYmd(2026, 5, 8)), + ); +} diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig new file mode 100644 index 0000000..b0d019e --- /dev/null +++ b/src/commands/audit/hygiene.zig @@ -0,0 +1,1536 @@ +//! Flagless portfolio hygiene check for the `audit` command. +//! +//! When `zfin audit` runs with no brokerage flag, this is what +//! executes: stale-manual-price detection, working-tree-vs-HEAD +//! price/date mismatch detection, account-cadence overdue checks, +//! automatic brokerage-file discovery + reconciliation, and the +//! large-new-lot transfer nudge. +//! +//! The reconciliation in Section 4 delegates to the per-broker +//! modules (`fidelity.zig`, `schwab.zig`) and the shared display in +//! `common.zig`; this file owns only the hygiene logic, so a future +//! `zfin doctor` can reuse it without pulling in the reconciler +//! surface. + +const std = @import("std"); +const zfin = @import("../../root.zig"); +const cli = @import("../common.zig"); +const framework = @import("../framework.zig"); +const contributions = @import("../contributions.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 srf = @import("srf"); +const git = @import("../../git.zig"); + +const common = @import("common.zig"); +const fidelity = @import("fidelity.zig"); +const schwab = @import("schwab.zig"); + +// ── Hygiene check (flagless audit) ────────────────────────── + +/// Constants for hygiene check behavior. Kept as named constants for +/// easy future tuning. +const audit_file_max_age_hours = 24; +const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only +pub const default_stale_days: u32 = 3; +const stale_warning_multiplier: u32 = 2; // yellow → red at 2× threshold + +/// Dollar threshold above which a new lot (new_stock / new_drip_lot / +/// new_cash / new_cd / cash_contribution) gets flagged in the +/// "Large new lots — confirm source" hygiene section. Below this +/// threshold new lots pass silently — the audit's goal is to catch +/// unconfirmed six-figure movements, not flag every payroll +/// contribution. +/// +/// $10k is a judgment call: high enough to ignore routine payroll +/// ESPP accruals and $1-$2k weekly deposits, low enough to surface +/// a typical IRA contribution or a genuine transfer. Tunable here, +/// per the plan's "revisit if the threshold proves wrong" note in +/// TODO.md. +const audit_large_lot_threshold: f64 = 10_000.0; + +/// Type of a discovered brokerage file. +const BrokerFileKind = enum { + fidelity_csv, + schwab_csv, + schwab_summary, +}; + +/// A discovered brokerage file ready for reconciliation. +const DiscoveredFile = struct { + path: []const u8, + kind: BrokerFileKind, + dir_label: []const u8, // e.g. "audit/" or "$ZFIN_AUDIT_FILES" +}; + +/// Detect the brokerage type from file contents by inspecting the first few lines. +fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind { + // Strip optional UTF-8 BOM + const content = if (data.len >= 3 and data[0] == 0xEF and data[1] == 0xBB and data[2] == 0xBF) + data[3..] + else + data; + + // Fidelity CSV: first line starts with "Account Number" or "Account Name" + if (std.mem.startsWith(u8, content, "Account Number") or + std.mem.startsWith(u8, content, "Account Name")) + return .fidelity_csv; + + // Schwab per-account CSV: starts with a quoted title line like "Positions for ..." + if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv; + + // Schwab summary: contains "Account number ending in" pattern + const peek = content[0..@min(content.len, 4096)]; + if (std.mem.indexOf(u8, peek, "Account number ending in") != null) return .schwab_summary; + // Also match by account type labels + dollar amounts + if ((std.mem.indexOf(u8, peek, "Brokerage") != null or + std.mem.indexOf(u8, peek, "Roth IRA") != null or + std.mem.indexOf(u8, peek, "Traditional IRA") != null or + std.mem.indexOf(u8, peek, "Rollover IRA") != null) and + std.mem.indexOf(u8, peek, "$") != null) + { + return .schwab_summary; + } + + return null; +} + +/// Discover brokerage files in a directory. Filters by recency (< 24h) +/// and applies size limits for non-CSV files. +fn discoverBrokerFiles( + io: std.Io, + allocator: std.mem.Allocator, + dir_path: []const u8, + dir_label: []const u8, + now_s: i64, +) ![]DiscoveredFile { + var results = std.ArrayList(DiscoveredFile).empty; + defer results.deinit(allocator); + + var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return try results.toOwnedSlice(allocator); + defer dir.close(io); + + const max_age_s: i128 = audit_file_max_age_hours * 3600; + + var it = dir.iterate(); + while (try it.next(io)) |entry| { + if (entry.kind != .file) continue; + + // Check file modification time + const stat = dir.statFile(io, entry.name, .{}) catch continue; + const mtime_s: i128 = @divFloor(stat.mtime.nanoseconds, std.time.ns_per_s); + const age_s = now_s - mtime_s; + if (age_s > max_age_s) continue; + + // Check if it's a CSV (no size limit) or non-CSV (size limit applies) + const is_csv = std.mem.endsWith(u8, entry.name, ".csv") or std.mem.endsWith(u8, entry.name, ".CSV"); + if (!is_csv and stat.size > audit_file_max_size_non_csv) continue; + + // Read and detect content type + const data = dir.readFileAlloc(io, entry.name, allocator, .limited(10 * 1024 * 1024)) catch continue; + defer allocator.free(data); + + const kind = detectBrokerFileKind(data) orelse continue; + const full_path = std.fs.path.join(allocator, &.{ dir_path, entry.name }) catch continue; + try results.append(allocator, .{ + .path = full_path, + .kind = kind, + .dir_label = dir_label, + }); + } + + return results.toOwnedSlice(allocator); +} + +/// Compute which accounts have been modified between two parsed portfolios. +/// Returns a set of account names that have any lot-level differences. +/// Compares by serializing each lot to a canonical string per account, +/// sorting, and checking for equality. Simple and robust -- any field +/// change in a lot produces a different string. +fn findModifiedAccounts( + allocator: std.mem.Allocator, + old_portfolio: zfin.Portfolio, + new_portfolio: zfin.Portfolio, +) !std.StringHashMap(void) { + var modified = std.StringHashMap(void).init(allocator); + errdefer modified.deinit(); + + // Collect serialized lot strings grouped by account + var old_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator); + defer { + var it = old_accts.valueIterator(); + while (it.next()) |v| { + for (v.items) |s| allocator.free(s); + v.deinit(allocator); + } + old_accts.deinit(); + } + var new_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator); + defer { + var it = new_accts.valueIterator(); + while (it.next()) |v| { + for (v.items) |s| allocator.free(s); + v.deinit(allocator); + } + new_accts.deinit(); + } + + for (old_portfolio.lots) |lot| { + const acct = lot.account orelse continue; + const entry = try old_accts.getOrPut(acct); + if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty; + try entry.value_ptr.append(allocator, try lotToString(allocator, lot)); + } + for (new_portfolio.lots) |lot| { + const acct = lot.account orelse continue; + const entry = try new_accts.getOrPut(acct); + if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty; + try entry.value_ptr.append(allocator, try lotToString(allocator, lot)); + } + + // Compare per account: sort both lists, then check equality + var all = std.StringHashMap(void).init(allocator); + defer all.deinit(); + { + var it = old_accts.keyIterator(); + while (it.next()) |k| try all.put(k.*, {}); + } + { + var it = new_accts.keyIterator(); + while (it.next()) |k| try all.put(k.*, {}); + } + + var acct_it = all.keyIterator(); + while (acct_it.next()) |acct_key| { + const acct = acct_key.*; + const old_ptr = old_accts.getPtr(acct); + const new_ptr = new_accts.getPtr(acct); + const old_len = if (old_ptr) |p| p.items.len else 0; + const new_len = if (new_ptr) |p| p.items.len else 0; + + if (old_len != new_len) { + try modified.put(acct, {}); + continue; + } + if (old_len == 0) continue; + + const old_items = old_ptr.?.items; + const new_items = new_ptr.?.items; + std.mem.sort([]const u8, old_items, {}, strLessThan); + std.mem.sort([]const u8, new_items, {}, strLessThan); + + var differs = false; + for (old_items, new_items) |a, b| { + if (!std.mem.eql(u8, a, b)) { + differs = true; + break; + } + } + if (differs) try modified.put(acct, {}); + } + + return modified; +} + +fn strLessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; +} + +/// Serialize a lot to a canonical SRF string for comparison. +/// Uses the SRF serializer with comptime reflection, so any new +/// field added to Lot is automatically included. +fn lotToString(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 { + const lots = [_]portfolio_mod.Lot{lot}; + return std.fmt.allocPrint(allocator, "{f}", .{srf.fmt(portfolio_mod.Lot, &lots, .{ .emit_directives = false })}); +} + +/// Staleness color based on age vs threshold. +/// Returns CLR_MUTED for within threshold, warning for 1-2x, negative for >2x. +fn stalenessColor(age_days: i32, threshold: u32) [3]u8 { + const t: i32 = @intCast(threshold); + if (age_days <= t) return cli.CLR_MUTED; + if (age_days <= t * @as(i32, stale_warning_multiplier)) return cli.CLR_WARNING; + return cli.CLR_NEGATIVE; +} + +/// One stale (or undated) manual price found during the hygiene scan. +/// String fields borrow from the scanned portfolio's lots and are +/// valid for the lifetime of that portfolio. +const StaleManualPrice = struct { + account: []const u8, + symbol: []const u8, + note: ?[]const u8, + price: f64, + /// `null` when the lot carries a manual `price` but no + /// `price_date` — the most-stale case (it can't even be aged), + /// not the least. + price_date: ?Date, + /// Days since `price_date`; `null` when undated. + age_days: ?i32, +}; + +/// Collect manual-priced lots that are stale (older than `stale_days`) +/// or undated, for the "Stale manual prices" hygiene section. +/// +/// Staleness is purely a property of the `price` / `price_date` the +/// user typed on the lot — it has nothing to do with the account's +/// `update_cadence` (that's the reconciliation cadence, a separate +/// concept handled by the "Accounts overdue" section). The single +/// threshold is `stale_days` (the `--stale-days` flag, default 3). +/// +/// Restricted to open `security_type == .stock` lots: CDs/cash/options +/// carry `price` as a fixed face value that never goes "stale by age," +/// and closed lots aren't worth nagging about. Undated manual prices +/// are always included regardless of `stale_days` — a manual price +/// with no `price_date` can't be aged, which is the worst case, not a +/// pass. Caller owns the returned list; string fields borrow from +/// `portfolio`. +fn collectStaleManualPrices( + allocator: std.mem.Allocator, + portfolio: zfin.Portfolio, + as_of: Date, + stale_days: u32, +) !std.ArrayList(StaleManualPrice) { + var out = std.ArrayList(StaleManualPrice).empty; + errdefer out.deinit(allocator); + + const threshold: i32 = @intCast(stale_days); + for (portfolio.lots) |lot| { + if (lot.security_type != .stock) continue; + const price = lot.price orelse continue; + if (!lot.isOpen(as_of)) continue; + const account = lot.account orelse "(no account)"; + + if (lot.price_date) |pd| { + const age = as_of.days - pd.days; + if (age <= threshold) continue; // fresh enough + try out.append(allocator, .{ + .account = account, + .symbol = lot.symbol, + .note = lot.note, + .price = price, + .price_date = pd, + .age_days = age, + }); + } else { + try out.append(allocator, .{ + .account = account, + .symbol = lot.symbol, + .note = lot.note, + .price = price, + .price_date = null, + .age_days = null, + }); + } + } + return out; +} + +/// Sort stale manual prices by account, then symbol — so the display +/// can group lines under per-account headers. +fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool { + const acc = std.mem.order(u8, a.account, b.account); + if (acc != .eq) return acc == .lt; + return std.mem.order(u8, a.symbol, b.symbol) == .lt; +} + +/// A lot whose manual `price` moved between HEAD and the working tree +/// while its `price_date` stayed identical — the "bumped the price, +/// forgot the date" mistake. String fields borrow from the working- +/// tree portfolio. +const PriceDateMismatch = struct { + account: []const u8, + symbol: []const u8, + old_price: f64, + new_price: f64, + price_date: Date, +}; + +/// Build a lot identity that survives a manual-price edit: a lot keeps +/// its symbol, account, open_date, and open_price when you only change +/// `price`/`price_date`. Used to pair HEAD lots with working-tree lots. +/// Caller owns the returned slice. +fn lotIdentityKey(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 { + return std.fmt.allocPrint(allocator, "{s}\x00{s}\x00{d}\x00{d:.6}", .{ + lot.symbol, + lot.account orelse "", + lot.open_date.days, + lot.open_price, + }); +} + +/// Find lots whose manual `price` changed between `committed` (HEAD) +/// and `working` (on-disk) while `price_date` stayed identical. +/// +/// This is the working-tree-vs-HEAD detector for the recurring "I +/// updated the price but forgot the date" mistake, run when `audit` +/// fires before a commit. Only open `security_type == .stock` lots +/// with a non-null `price_date` on both sides participate: the undated +/// case is reported by `collectStaleManualPrices`, and legitimate +/// back-dating (moving the date to a past close) is *not* flagged +/// because the date field changed. Lots are paired by +/// `lotIdentityKey`; newly-added or removed lots are ignored. Caller +/// owns the returned list; string fields borrow from `working`. +fn findPriceDateMismatches( + allocator: std.mem.Allocator, + committed: zfin.Portfolio, + working: zfin.Portfolio, + as_of: Date, +) !std.ArrayList(PriceDateMismatch) { + var out = std.ArrayList(PriceDateMismatch).empty; + errdefer out.deinit(allocator); + + // Index HEAD lots by stable identity → their (price, price_date). + const HeadLot = struct { price: ?f64, price_date: ?Date }; + var head = std.StringHashMap(HeadLot).init(allocator); + defer { + var it = head.keyIterator(); + while (it.next()) |k| allocator.free(k.*); + head.deinit(); + } + for (committed.lots) |lot| { + if (lot.security_type != .stock) continue; + const key = try lotIdentityKey(allocator, lot); + const gop = try head.getOrPut(key); + if (gop.found_existing) { + // Duplicate identity (rare) — ambiguous, don't guess. + allocator.free(key); + continue; + } + gop.value_ptr.* = .{ .price = lot.price, .price_date = lot.price_date }; + } + + for (working.lots) |lot| { + if (lot.security_type != .stock) continue; + if (!lot.isOpen(as_of)) continue; + const new_price = lot.price orelse continue; + const new_date = lot.price_date orelse continue; // undated → stale-price section's job + const key = try lotIdentityKey(allocator, lot); + defer allocator.free(key); + const prior = head.get(key) orelse continue; // newly-added lot + const old_price = prior.price orelse continue; // price added, not moved + const old_date = prior.price_date orelse continue; // was undated before + // The mistake: price moved, date did not. + if (old_date.days == new_date.days and @abs(old_price - new_price) >= 0.005) { + try out.append(allocator, .{ + .account = lot.account orelse "(no account)", + .symbol = lot.symbol, + .old_price = old_price, + .new_price = new_price, + .price_date = new_date, + }); + } + } + return out; +} + +/// Sort price/date mismatches by account, then symbol — for grouped +/// per-account display. +fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool { + const acc = std.mem.order(u8, a.account, b.account); + if (acc != .eq) return acc == .lt; + return std.mem.order(u8, a.symbol, b.symbol) == .lt; +} + +/// Render one unmatched large-lot warning. Formats the line the +/// user needs to paste into `transaction_log.srf` if the lot was +/// an internal movement rather than a real external contribution. +/// Leaves `from::` as a placeholder — the audit doesn't +/// know which account the money came from. +/// +/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash +/// (or cash_contribution) destinations use `dest_lot::cash`. The +/// template always uses `type::cash` since `type::in_kind` is +/// rejected downstream in v1. +fn printLargeLotWarning( + out: *std.Io.Writer, + lot: contributions.UnmatchedLargeLot, + color: bool, +) !void { + var val_buf: [32]u8 = undefined; + var date_buf: [10]u8 = undefined; + const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?"; + const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??"; + const kind_label: []const u8 = switch (lot.security_type) { + .stock => "STOCK", + .cash => "CASH", + .cd => "CD", + .option => "OPTION", + else => "LOT", + }; + + const sym_for_display = if (lot.symbol.len > 0) lot.symbol else "cash"; + try out.print( + " {s}: new {s} lot {s} ", + .{ lot.account, kind_label, sym_for_display }, + ); + try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str}); + try out.print(" on {s}\n", .{date_str}); + + try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{}); + + // Amount formatted with cents precision so the suggested + // `amount:num:N` exactly matches the lot's value. The matcher + // has a $1 tolerance so a whole-dollar suggestion would usually + // pair, but pasting a value that lies about the actual lot is + // a poor user experience — `transaction_log.srf` should record + // what actually moved. + if (lot.security_type == .cash) { + try cli.printFg( + out, + color, + cli.CLR_MUTED, + " transfer::{s},type::cash,amount:num:{d:.2},from::,to::{s},dest_lot::cash\n", + .{ date_str, lot.value, lot.account }, + ); + } else { + try cli.printFg( + out, + color, + cli.CLR_MUTED, + " transfer::{s},type::cash,amount:num:{d:.2},from::,to::{s},dest_lot::{s}@{s}\n", + .{ date_str, lot.value, lot.account, lot.symbol, date_str }, + ); + } +} + +/// Run the flagless portfolio hygiene check. +pub fn runHygieneCheck( + io: std.Io, + allocator: std.mem.Allocator, + env: *const std.process.Environ.Map, + svc: *zfin.DataService, + portfolio_path: []const u8, + stale_days: u32, + verbose: bool, + as_of: Date, + now_s: i64, + color: bool, + refresh: framework.RefreshPolicy, + out: *std.Io.Writer, +) !void { + // Load portfolio + const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch { + cli.stderrPrint(io, "Error: Cannot read portfolio file\n"); + return; + }; + defer allocator.free(pf_data); + + var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch { + cli.stderrPrint(io, "Error: Cannot parse portfolio file\n"); + return; + }; + defer portfolio.deinit(); + + // Load accounts.srf + var account_map = svc.loadAccountMap(allocator, portfolio_path) orelse { + cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n"); + return; + }; + defer account_map.deinit(); + + try cli.printBold(out, color, " Portfolio hygiene\n", .{}); + + // ── Section 1: Stale manual prices ── + // + // Manual prices on stock/fund lots whose `price_date` is older than + // `--stale-days` (default 3), plus manual prices with no + // `price_date` at all (the most-stale case). Staleness is a + // property of the price the user typed on the lot — not of the + // account; grouping by account here is display-only organization. + // CDs/cash/options are excluded — their `price` is a fixed face + // value, not an age-stale quote — as are closed lots. + { + var stale = try collectStaleManualPrices(allocator, portfolio, as_of, stale_days); + defer stale.deinit(allocator); + std.mem.sort(StaleManualPrice, stale.items, {}, staleLessThan); + + try out.print("\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days — --stale-days to configure)\n", .{stale_days}); + + if (stale.items.len == 0) { + try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{}); + } else { + var current_account: ?[]const u8 = null; + for (stale.items) |e| { + if (current_account == null or !std.mem.eql(u8, current_account.?, e.account)) { + current_account = e.account; + try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{e.account}); + } + + var price_buf: [24]u8 = undefined; + const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(e.price)}) catch "$?"; + const note_display = e.note orelse ""; + + if (e.price_date) |pd| { + var date_buf: [10]u8 = undefined; + const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{pd}) catch "????-??-??"; + try out.print(" {s:<14} {s:<16} {s:>12} {s} ", .{ e.symbol, note_display, price_str, date_str }); + const clr = stalenessColor(e.age_days.?, stale_days); + try cli.printFg(out, color, clr, "({d} days)\n", .{@as(u32, @intCast(e.age_days.?))}); + } else { + try out.print(" {s:<14} {s:<16} {s:>12} ", .{ e.symbol, note_display, price_str }); + try cli.printFg(out, color, cli.CLR_NEGATIVE, "(no price_date set)\n", .{}); + } + } + } + } + + // ── Section 2: Account cadence check ── + { + // Try to get committed version via git + const repo_info: ?git.RepoInfo = git.findRepo(io, allocator, env, portfolio_path) catch null; + defer if (repo_info) |ri| { + allocator.free(ri.root); + allocator.free(ri.rel_path); + }; + + // Parse committed portfolio for diff (working copy vs HEAD) + var committed_portfolio: ?zfin.Portfolio = null; + defer if (committed_portfolio) |*cp| cp.deinit(); + + var committed_data: ?[]const u8 = null; + defer if (committed_data) |d| allocator.free(d); + + if (repo_info) |ri| { + committed_data = git.show(io, allocator, env, ri.root, "HEAD", ri.rel_path) catch null; + if (committed_data) |cd| { + committed_portfolio = zfin.cache.deserializePortfolio(allocator, cd) catch null; + } + } + + // ── Section 1b: manual price changed without bumping price_date ── + // + // Catches the recurring "I updated the price but forgot the + // date" mistake at the moment it matters — when `audit` runs + // against the working tree before a commit. Diffs the on-disk + // portfolio against HEAD; a lot whose `price` moved while its + // `price_date` stayed put is flagged. Silent when there's no + // committed version to compare against (not a repo / new file) + // and when no such mismatch exists. + if (committed_portfolio) |cp| { + var mismatches = try findPriceDateMismatches(allocator, cp, portfolio, as_of); + defer mismatches.deinit(allocator); + std.mem.sort(PriceDateMismatch, mismatches.items, {}, mismatchLessThan); + + if (mismatches.items.len > 0) { + try out.print("\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Manual price changed without updating price_date (working tree vs HEAD)\n", .{}); + var current_account: ?[]const u8 = null; + for (mismatches.items) |m| { + if (current_account == null or !std.mem.eql(u8, current_account.?, m.account)) { + current_account = m.account; + try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{m.account}); + } + var old_buf: [24]u8 = undefined; + var new_buf: [24]u8 = undefined; + var date_buf: [10]u8 = undefined; + const old_str = std.fmt.bufPrint(&old_buf, "{f}", .{Money.from(m.old_price)}) catch "$?"; + const new_str = std.fmt.bufPrint(&new_buf, "{f}", .{Money.from(m.new_price)}) catch "$?"; + const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{m.price_date}) catch "????-??-??"; + try out.print(" {s:<14} {s} → {s} ", .{ m.symbol, old_str, new_str }); + try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} — bump it\n", .{date_str}); + } + } + } + + // Find accounts modified in working copy (uncommitted changes) + var working_copy_modified = std.StringHashMap(void).init(allocator); + defer working_copy_modified.deinit(); + + if (committed_portfolio) |cp| { + working_copy_modified = findModifiedAccounts(allocator, cp, portfolio) catch std.StringHashMap(void).init(allocator); + } + + // Collect all unique account names from working copy portfolio + // (these pointers are stable for the lifetime of the function) + var all_accounts = std.StringHashMap(void).init(allocator); + defer all_accounts.deinit(); + for (portfolio.lots) |lot| { + if (lot.account) |acct| { + try all_accounts.put(acct, {}); + } + } + + // Find last update time for each account via git history. + // Walk commits newest-to-oldest, diffing adjacent pairs to find + // which accounts changed. Use working-copy account names as keys + // (stable lifetime) rather than historical portfolio strings. + // Only walk back far enough to hit red status (2× max cadence). + var last_update_ts = std.StringHashMap(i64).init(allocator); + defer last_update_ts.deinit(); + + if (repo_info) |ri| { + // Compute the furthest we need to look back: 2× the max cadence + var max_threshold: u32 = 14; // 2× weekly default + for (account_map.entries) |entry| { + if (entry.update_cadence.thresholdDays()) |td| { + const red = td * stale_warning_multiplier; + if (red > max_threshold) max_threshold = red; + } + } + var since_buf: [32]u8 = undefined; + const since = std.fmt.bufPrint(&since_buf, "{d} days ago", .{max_threshold}) catch "30 days ago"; + + const commits = git.listCommitsTouching(io, allocator, env, ri.root, ri.rel_path, since) catch &.{}; + defer git.freeCommitTouches(allocator, commits); + + var prev_data: ?[]const u8 = null; + defer if (prev_data) |pd| allocator.free(pd); + + for (commits, 0..) |ct, ci| { + // Stop early if every account already has a timestamp + if (last_update_ts.count() >= all_accounts.count()) break; + + const rev_data = git.show(io, allocator, env, ri.root, ct.commit, ri.rel_path) catch continue; + + if (ci > 0) { + if (prev_data) |pd| { + // rev_data is older, pd is newer (commits are newest-first) + var old_pf = zfin.cache.deserializePortfolio(allocator, rev_data) catch { + allocator.free(rev_data); + continue; + }; + defer old_pf.deinit(); + var new_pf = zfin.cache.deserializePortfolio(allocator, pd) catch continue; + defer new_pf.deinit(); + + var mods = findModifiedAccounts(allocator, old_pf, new_pf) catch continue; + defer mods.deinit(); + + // The newer commit's timestamp is when these accounts were updated + const update_ts = commits[ci - 1].timestamp; + + // Match against stable working-copy account names + var acct_iter = all_accounts.keyIterator(); + while (acct_iter.next()) |stable_name| { + if (last_update_ts.contains(stable_name.*)) continue; + if (mods.contains(stable_name.*)) { + try last_update_ts.put(stable_name.*, update_ts); + } + } + } + } + + if (prev_data) |pd| allocator.free(pd); + prev_data = rev_data; + } + } + + // Display overdue accounts + var overdue_header_shown = false; + var updated_accounts = std.ArrayList([]const u8).empty; + defer updated_accounts.deinit(allocator); + + // Check accounts updated in working copy + var wc_it = working_copy_modified.keyIterator(); + while (wc_it.next()) |key| { + try updated_accounts.append(allocator, key.*); + } + + // Check overdue accounts + var acct_it = all_accounts.keyIterator(); + while (acct_it.next()) |acct_key| { + const acct_name = acct_key.*; + + // Skip if already updated in working copy + if (working_copy_modified.contains(acct_name)) continue; + + // Look up cadence from accounts.srf + var cadence = analysis.UpdateCadence.weekly; // default + for (account_map.entries) |entry| { + if (std.mem.eql(u8, entry.account, acct_name)) { + cadence = entry.update_cadence; + break; + } + } + + const threshold_days = cadence.thresholdDays() orelse continue; // skip 'none' + + // Find last update time + var age_days: ?i32 = null; + if (last_update_ts.get(acct_name)) |ts| { + const age_s = now_s - ts; + age_days = @intCast(@divFloor(age_s, std.time.s_per_day)); + } + + // If we have no git history for this account, it's definitely overdue + const days = age_days orelse @as(i32, @intCast(threshold_days + 1)); + if (days <= @as(i32, @intCast(threshold_days))) continue; + + if (!overdue_header_shown) { + try out.print("\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default — set update_cadence in accounts.srf)\n", .{}); + overdue_header_shown = true; + } + + try out.print(" {s:<32} {s:<10}", .{ acct_name, cadence.label() }); + if (age_days) |ad| { + const clr = stalenessColor(ad, threshold_days); + try cli.printFg(out, color, clr, "last updated {d} days ago\n", .{@as(u32, @intCast(ad))}); + } else { + try cli.printFg(out, color, cli.CLR_NEGATIVE, "no update history found\n", .{}); + } + } + + // Display accounts updated in working copy + if (updated_accounts.items.len > 0) { + try out.print("\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Accounts updated (working copy)\n", .{}); + for (updated_accounts.items) |acct| { + try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}\n", .{acct}); + } + } + } + + // ── Section 3: Discover brokerage files ── + + // Resolve audit directories + const portfolio_dir = std.fs.path.dirnamePosix(portfolio_path) orelse "."; + + var all_files = std.ArrayList(DiscoveredFile).empty; + defer { + for (all_files.items) |f| allocator.free(f.path); + all_files.deinit(allocator); + } + + // Check $ZFIN_AUDIT_FILES first + const env_audit_dir = if (svc.config.environ_map) |em| em.get("ZFIN_AUDIT_FILES") else null; + if (env_audit_dir) |edir| { + const env_files = try discoverBrokerFiles(io, allocator, edir, "$ZFIN_AUDIT_FILES", now_s); + defer allocator.free(env_files); + for (env_files) |f| try all_files.append(allocator, f); + } + + // Then check {portfolio_dir}/audit/ + const default_audit_dir = std.fs.path.join(allocator, &.{ portfolio_dir, "audit" }) catch null; + defer if (default_audit_dir) |d| allocator.free(d); + + if (default_audit_dir) |adir| { + const dir_files = try discoverBrokerFiles(io, allocator, adir, "audit/", now_s); + defer allocator.free(dir_files); + for (dir_files) |f| try all_files.append(allocator, f); + } + + // Display discovered files + if (all_files.items.len > 0) { + try out.print("\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Brokerage files (last {d} hours)\n", .{audit_file_max_age_hours}); + for (all_files.items) |f| { + const kind_label: []const u8 = switch (f.kind) { + .fidelity_csv => "fidelity", + .schwab_csv => "schwab csv", + .schwab_summary => "schwab summary", + }; + try out.print(" {s:<52} {s}\n", .{ f.path, kind_label }); + } + } + + // ── Section 4: Auto-reconcile discovered files ── + + if (all_files.items.len > 0) { + // Build prices map (shared by all reconciliations) + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + { + const pos_syms = try portfolio.stockSymbols(allocator); + defer allocator.free(pos_syms); + if (pos_syms.len > 0) { + var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, refresh, color); + defer load_result.deinit(); + var pit = load_result.prices.iterator(); + while (pit.next()) |entry| { + try prices.put(entry.key_ptr.*, entry.value_ptr.*); + } + } + for (portfolio.lots) |lot| { + if (lot.price) |p| { + if (!prices.contains(lot.priceSymbol())) { + try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false)); + } + } + } + } + + try out.print("\n", .{}); + try cli.printBold(out, color, " Reconciliation\n", .{}); + + for (all_files.items) |f| { + const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue; + defer allocator.free(file_data); + + switch (f.kind) { + .schwab_summary => { + const results = schwab.reconcileSummary(allocator, portfolio, file_data, account_map, prices, as_of) catch continue; + defer allocator.free(results); + + const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results); + defer allocator.free(present); + const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); + defer allocator.free(absent); + + if (verbose or schwab.hasSchwabDiscrepancies(results)) { + try out.print("\n", .{}); + try schwab.displaySchwabResults(results, color, out); + try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); + } else { + var acct_count: usize = 0; + for (results) |r| { + if (r.account_name.len > 0) acct_count += 1; + } + try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count}); + // Always show ratio suggestions even in compact + // mode — direct-indexing drift may cause a + // non-zero delta that still deserves a nudge. + try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); + } + try common.displayAbsentAccounts(absent, color, out); + }, + .fidelity_csv => { + const results = fidelity.reconcile(allocator, portfolio, file_data, account_map, prices, as_of) catch continue; + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + const present = try common.presentNumbers(allocator, common.AccountComparison, results); + defer allocator.free(present); + const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of); + defer allocator.free(absent); + + if (verbose or common.hasAccountDiscrepancies(results)) { + try out.print("\n", .{}); + try common.displayResults(results, color, out); + try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); + } else { + try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len}); + // Always show ratio suggestions even in compact mode + try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); + } + try common.displayAbsentAccounts(absent, color, out); + }, + .schwab_csv => { + const results = schwab.reconcileCsv(allocator, portfolio, file_data, account_map, prices, as_of) catch continue; + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + const present = try common.presentNumbers(allocator, common.AccountComparison, results); + defer allocator.free(present); + const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); + defer allocator.free(absent); + + if (verbose or common.hasAccountDiscrepancies(results)) { + try out.print("\n", .{}); + try common.displayResults(results, color, out); + try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); + } else { + try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len}); + try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); + } + try common.displayAbsentAccounts(absent, color, out); + }, + } + } + } + + // ── Section 5: Large new lots — confirm source ── + // + // Cross-check any new_* Change with value >= threshold against + // `transaction_log.srf` (via the shared contributions pipeline). + // Surfaces lots that look like significant external contributions + // OR unrecorded internal transfers — nudges the user to either + // confirm or add a transfer record. + // + // Silent when every large lot matched a transfer record, when + // there are no new lots at all, or when the pipeline can't run + // (not in a git repo). Threshold is a judgment call; see + // `audit_large_lot_threshold`. + if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_path, audit_large_lot_threshold, as_of, color, refresh)) |found| { + var found_mut = found; + defer found_mut.deinit(); + + if (found_mut.lots.len > 0) { + try out.print("\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots — confirm source\n", .{}); + for (found_mut.lots) |lot| { + try printLargeLotWarning(out, lot, color); + } + } + } + + try out.print("\n", .{}); +} + +// ── Tests ──────────────────────────────────────────────────── + +test "detectBrokerFileKind: fidelity csv" { + const fidelity_header = "Account Number,Account Name,Symbol,Description"; + try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?); +} + +test "detectBrokerFileKind: fidelity csv with BOM" { + const fidelity_bom = "\xEF\xBB\xBFAccount Number,Account Name,Symbol"; + try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_bom).?); +} + +test "detectBrokerFileKind: schwab csv" { + const schwab_header = "\"Positions for account Roth IRA ...1234 as of\""; + try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(schwab_header).?); +} + +test "detectBrokerFileKind: schwab summary" { + const schwab_summary_data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00"; + try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(schwab_summary_data).?); +} + +test "detectBrokerFileKind: unknown file" { + const random_data = "This is just some random text that doesn't match any pattern"; + try std.testing.expect(detectBrokerFileKind(random_data) == null); +} + +test "stalenessColor: within threshold" { + try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(2, 3)); + try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(3, 3)); +} + +test "stalenessColor: warning zone (1-2x threshold)" { + try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(4, 3)); + try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(6, 3)); +} + +test "stalenessColor: critical zone (>2x threshold)" { + try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(7, 3)); + try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(30, 3)); +} + +test "UpdateCadence thresholdDays" { + try std.testing.expectEqual(@as(?u32, 7), analysis.UpdateCadence.weekly.thresholdDays()); + try std.testing.expectEqual(@as(?u32, 30), analysis.UpdateCadence.monthly.thresholdDays()); + try std.testing.expectEqual(@as(?u32, 90), analysis.UpdateCadence.quarterly.thresholdDays()); + try std.testing.expect(analysis.UpdateCadence.none.thresholdDays() == null); +} + +test "findModifiedAccounts: detects share changes" { + const allocator = std.testing.allocator; + + var old_lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, + .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, + }; + var new_lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AAPL", .shares = 110, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, // shares changed + .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, // unchanged + }; + + const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; + const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; + + var modified = try findModifiedAccounts(allocator, old_pf, new_pf); + defer modified.deinit(); + + try std.testing.expect(modified.contains("Acct A")); + try std.testing.expect(!modified.contains("Acct B")); +} + +test "findModifiedAccounts: detects new lots" { + const allocator = std.testing.allocator; + + var old_lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, + }; + var new_lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, + .{ .symbol = "VTI", .shares = 200, .open_date = Date.fromYmd(2025, 3, 1), .open_price = 200.0, .account = "Acct A" }, + }; + + const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; + const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; + + var modified = try findModifiedAccounts(allocator, old_pf, new_pf); + defer modified.deinit(); + + try std.testing.expect(modified.contains("Acct A")); +} + +test "findModifiedAccounts: detects price changes" { + const allocator = std.testing.allocator; + + var old_lots = [_]portfolio_mod.Lot{ + .{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9) }, + }; + var new_lots = [_]portfolio_mod.Lot{ + .{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18) }, + }; + + const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; + const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; + + var modified = try findModifiedAccounts(allocator, old_pf, new_pf); + defer modified.deinit(); + + try std.testing.expect(modified.contains("401k")); +} + +test "findModifiedAccounts: detects removed lots" { + const allocator = std.testing.allocator; + + var old_lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, + .{ .symbol = "VTI", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Acct A" }, + }; + var new_lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, + // VTI removed + }; + + const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; + const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; + + var modified = try findModifiedAccounts(allocator, old_pf, new_pf); + defer modified.deinit(); + + try std.testing.expect(modified.contains("Acct A")); +} + +test "findModifiedAccounts: no changes" { + const allocator = std.testing.allocator; + + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, + }; + + const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var modified = try findModifiedAccounts(allocator, pf, pf); + defer modified.deinit(); + + try std.testing.expectEqual(@as(u32, 0), modified.count()); +} + +// ── collectStaleManualPrices ───────────────────────────────── + +test "collectStaleManualPrices: dated stale stock lot is flagged with account + age" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, + }; + const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); + defer stale.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 1), stale.items.len); + try std.testing.expectEqualStrings("Sample 529", stale.items[0].account); + try std.testing.expectEqualStrings("F529A", stale.items[0].symbol); + try std.testing.expectEqual(@as(?i32, 31), stale.items[0].age_days); +} + +test "collectStaleManualPrices: price within threshold is skipped" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 30) }, + }; + const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); + defer stale.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), stale.items.len); +} + +test "collectStaleManualPrices: undated manual price is always flagged" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = null }, + }; + const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); + defer stale.deinit(allocator); + try std.testing.expectEqual(@as(usize, 1), stale.items.len); + try std.testing.expectEqual(@as(?Date, null), stale.items[0].price_date); + try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days); +} + +test "collectStaleManualPrices: CDs and cash are excluded" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2020, 1, 1) }, + .{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cash, .price = 5000.0, .price_date = Date.fromYmd(2020, 1, 1) }, + }; + const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); + defer stale.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), stale.items.len); +} + +test "collectStaleManualPrices: closed lot is excluded" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2020, 1, 1), .close_date = Date.fromYmd(2026, 1, 1), .close_price = 26.0 }, + }; + const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); + defer stale.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), stale.items.len); +} + +test "collectStaleManualPrices: lot without a manual price is skipped" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" }, + }; + const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); + defer stale.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), stale.items.len); +} + +// ── findPriceDateMismatches ────────────────────────────────── + +test "findPriceDateMismatches: price moved but date unchanged is flagged" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var head = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, + }; + var work = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) }, + }; + const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; + const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; + + var m = try findPriceDateMismatches(allocator, cp, wp, as_of); + defer m.deinit(allocator); + try std.testing.expectEqual(@as(usize, 1), m.items.len); + try std.testing.expectEqualStrings("F529A", m.items[0].symbol); + try std.testing.expectEqual(@as(f64, 25.0), m.items[0].old_price); + try std.testing.expectEqual(@as(f64, 27.5), m.items[0].new_price); +} + +test "findPriceDateMismatches: price moved AND date moved (back-date) is not flagged" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var head = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, + }; + var work = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 29) }, + }; + const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; + const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; + var m = try findPriceDateMismatches(allocator, cp, wp, as_of); + defer m.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), m.items.len); +} + +test "findPriceDateMismatches: unchanged price is not flagged" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, + }; + const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + var m = try findPriceDateMismatches(allocator, pf, pf, as_of); + defer m.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), m.items.len); +} + +test "findPriceDateMismatches: newly-added lot is not flagged" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var head = [_]portfolio_mod.Lot{}; + var work = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) }, + }; + const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; + const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; + var m = try findPriceDateMismatches(allocator, cp, wp, as_of); + defer m.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), m.items.len); +} + +test "findPriceDateMismatches: undated working lot is left to the stale-price section" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var head = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, + }; + var work = [_]portfolio_mod.Lot{ + .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = null }, + }; + const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; + const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; + var m = try findPriceDateMismatches(allocator, cp, wp, as_of); + defer m.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), m.items.len); +} + +test "findPriceDateMismatches: CD price change is ignored" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 1); + var head = [_]portfolio_mod.Lot{ + .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2026, 5, 1) }, + }; + var work = [_]portfolio_mod.Lot{ + .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10100.0, .price_date = Date.fromYmd(2026, 5, 1) }, + }; + const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; + const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; + var m = try findPriceDateMismatches(allocator, cp, wp, as_of); + defer m.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), m.items.len); +} + +test "detectBrokerFileKind: schwab csv with Positions header" { + const data = "\"Positions for account Brokerage ...1234 as of 11:31 AM ET, 2026/04/25\"\n\nSymbol,Description,Quantity"; + try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?); +} + +test "detectBrokerFileKind: schwab summary with Roth IRA" { + const data = "Roth IRA ...1234\nSome text\n$50,000.00\n"; + try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(data).?); +} + +test "UpdateCadence label" { + try std.testing.expectEqualStrings("weekly", analysis.UpdateCadence.weekly.label()); + try std.testing.expectEqualStrings("monthly", analysis.UpdateCadence.monthly.label()); + try std.testing.expectEqualStrings("quarterly", analysis.UpdateCadence.quarterly.label()); + try std.testing.expectEqualStrings("none", analysis.UpdateCadence.none.label()); +} + +test "discoverBrokerFiles: finds files in temp directory" { + const io = std.testing.io; + const allocator = std.testing.allocator; + + // Create a temp directory with test files + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + // Write a fidelity CSV + tmp.dir.writeFile(io, .{ + .sub_path = "fidelity.csv", + .data = "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Current Value\nZ123,Test,AAPL,Apple,100,200,20000\n", + }) catch unreachable; + + // Write a schwab summary (non-CSV) + tmp.dir.writeFile(io, .{ + .sub_path = "schwab.txt", + .data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00\n", + }) catch unreachable; + + // Write a random non-matching file + tmp.dir.writeFile(io, .{ + .sub_path = "notes.txt", + .data = "Just some random notes", + }) catch unreachable; + + // Get the temp dir path + const tmp_path = tmp.dir.realPathFileAlloc(io, ".", allocator) catch unreachable; + defer allocator.free(tmp_path); + + // wall-clock required: test writes real files and verifies they're + // treated as fresh. A fixed synthetic `now_s` would drift relative + // to the file mtime and produce flaky results. + const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); + const files = try discoverBrokerFiles(io, allocator, tmp_path, "test/", now_s); + defer { + for (files) |f| allocator.free(f.path); + allocator.free(files); + } + + // Should find fidelity CSV and schwab summary, but not notes.txt + try std.testing.expectEqual(@as(usize, 2), files.len); + + var found_fidelity = false; + var found_schwab = false; + for (files) |f| { + switch (f.kind) { + .fidelity_csv => found_fidelity = true, + .schwab_summary => found_schwab = true, + else => {}, + } + } + try std.testing.expect(found_fidelity); + try std.testing.expect(found_schwab); +} + +test "discoverBrokerFiles: empty directory returns empty" { + const io = std.testing.io; + const allocator = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const tmp_path = tmp.dir.realPathFileAlloc(io, ".", allocator) catch unreachable; + defer allocator.free(tmp_path); + + const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); + const files = try discoverBrokerFiles(io, allocator, tmp_path, "test/", now_s); + defer allocator.free(files); + + try std.testing.expectEqual(@as(usize, 0), files.len); +} + +test "discoverBrokerFiles: nonexistent directory returns empty" { + const io = std.testing.io; + const allocator = std.testing.allocator; + + const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); + const files = try discoverBrokerFiles(io, allocator, "/nonexistent/path/audit", "test/", now_s); + defer allocator.free(files); + + try std.testing.expectEqual(@as(usize, 0), files.len); +} + +test "printLargeLotWarning: cash destination emits dest_lot::cash template" { + var buf: [1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&buf); + + const lot: contributions.UnmatchedLargeLot = .{ + .account = "Acct A", + .symbol = "", + .security_type = .cash, + .value = 50_000.0, + .open_date = Date.fromYmd(2026, 5, 10), + }; + + try printLargeLotWarning(&writer, lot, false); // color=false → no ANSI escapes + const output = writer.buffered(); + + // Header line with account + value + date. + try std.testing.expect(std.mem.indexOf(u8, output, "Acct A: new CASH lot cash") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "+$50,000.00") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null); + + // Template line with the expected SRF shape. + try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000.00,from::,to::Acct A,dest_lot::cash") != null); +} + +test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" { + var buf: [1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&buf); + + const lot: contributions.UnmatchedLargeLot = .{ + .account = "Acct B", + .symbol = "SYM", + .security_type = .stock, + .value = 25_000.0, + .open_date = Date.fromYmd(2026, 5, 3), + }; + + try printLargeLotWarning(&writer, lot, false); + const output = writer.buffered(); + + try std.testing.expect(std.mem.indexOf(u8, output, "Acct B: new STOCK lot SYM") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "+$25,000.00") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000.00,from::,to::Acct B,dest_lot::SYM@2026-05-03") != null); +} + +test "printLargeLotWarning: cents are preserved in template" { + // Regression: previously the template rounded to whole dollars, + // so a $73,158.33 lot suggested `amount:num:73158`. Pasting that + // verbatim into transaction_log.srf records a fictitious amount + // and (with $1 matcher tolerance) only barely pairs. The fix + // prints two-decimal precision so the suggested record exactly + // describes the lot it's offering to attribute. + var buf: [1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&buf); + + const lot: contributions.UnmatchedLargeLot = .{ + .account = "Sample Trust", + .symbol = "", + .security_type = .cash, + .value = 73_158.33, + .open_date = Date.fromYmd(2026, 5, 20), + }; + + try printLargeLotWarning(&writer, lot, false); + const output = writer.buffered(); + + try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158.33") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158,") == null); +} + +test "strLessThan: orders strings lexicographically" { + try std.testing.expect(strLessThan({}, "AAPL", "MSFT")); + try std.testing.expect(!strLessThan({}, "MSFT", "AAPL")); + try std.testing.expect(!strLessThan({}, "AAPL", "AAPL")); + try std.testing.expect(strLessThan({}, "AAPL", "AAPLE")); +} + +test "lotToString: stock lot includes symbol, shares, date" { + const allocator = std.testing.allocator; + const lot = portfolio_mod.Lot{ + .symbol = "AAPL", + .shares = 100, + .open_date = Date.fromYmd(2024, 3, 15), + .open_price = 150.50, + }; + const s = try lotToString(allocator, lot); + defer allocator.free(s); + try std.testing.expect(std.mem.indexOf(u8, s, "AAPL") != null); + try std.testing.expect(std.mem.indexOf(u8, s, "100") != null); + try std.testing.expect(std.mem.indexOf(u8, s, "2024-03-15") != null); +} + +test "staleLessThan: orders by account, then symbol" { + const a = StaleManualPrice{ .account = "Sample IRA", .symbol = "AAPL", .note = null, .price = 1, .price_date = null, .age_days = null }; + const b = StaleManualPrice{ .account = "Sample IRA", .symbol = "MSFT", .note = null, .price = 1, .price_date = null, .age_days = null }; + const c = StaleManualPrice{ .account = "Sample Roth", .symbol = "AAA", .note = null, .price = 1, .price_date = null, .age_days = null }; + + // Same account → symbol breaks the tie. + try std.testing.expect(staleLessThan({}, a, b)); + try std.testing.expect(!staleLessThan({}, b, a)); + // Different account → account wins regardless of symbol. + try std.testing.expect(staleLessThan({}, b, c)); + try std.testing.expect(!staleLessThan({}, c, b)); +} + +test "mismatchLessThan: orders by account, then symbol" { + const d = Date.fromYmd(2026, 1, 1); + const a = PriceDateMismatch{ .account = "Sample IRA", .symbol = "AAPL", .old_price = 1, .new_price = 2, .price_date = d }; + const b = PriceDateMismatch{ .account = "Sample IRA", .symbol = "MSFT", .old_price = 1, .new_price = 2, .price_date = d }; + const c = PriceDateMismatch{ .account = "Sample Roth", .symbol = "AAA", .old_price = 1, .new_price = 2, .price_date = d }; + + try std.testing.expect(mismatchLessThan({}, a, b)); + try std.testing.expect(!mismatchLessThan({}, b, a)); + try std.testing.expect(mismatchLessThan({}, b, c)); + try std.testing.expect(!mismatchLessThan({}, c, b)); +} + +test "findPriceDateMismatches: duplicate HEAD identity collapses to one entry" { + const allocator = std.testing.allocator; + + // Two committed lots share an identity key (same symbol/account/ + // open_date/open_price) → the second is ambiguous and skipped, but + // the first still anchors the comparison. + var committed_lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) }, + .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) }, + }; + const committed = portfolio_mod.Portfolio{ .lots = &committed_lots, .allocator = allocator }; + + // Working tree bumps the price but leaves price_date untouched. + var working_lots = [_]portfolio_mod.Lot{ + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 160, .price_date = Date.fromYmd(2026, 1, 1) }, + }; + const working = portfolio_mod.Portfolio{ .lots = &working_lots, .allocator = allocator }; + + var mismatches = try findPriceDateMismatches(allocator, committed, working, Date.fromYmd(2026, 6, 1)); + defer mismatches.deinit(allocator); + + // Dup HEAD identity collapsed to one; the price-without-date bump still flags. + try std.testing.expectEqual(@as(usize, 1), mismatches.items.len); + try std.testing.expectEqualStrings("AAPL", mismatches.items[0].symbol); + try std.testing.expectApproxEqAbs(@as(f64, 150), mismatches.items[0].old_price, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 160), mismatches.items[0].new_price, 0.01); +} diff --git a/src/commands/audit/schwab.zig b/src/commands/audit/schwab.zig new file mode 100644 index 0000000..cb5aa9d --- /dev/null +++ b/src/commands/audit/schwab.zig @@ -0,0 +1,832 @@ +//! 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`. + +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 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, + 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; + + if (portfolio_acct) |pa| { + pf_cash = portfolio.cashForAccount(pa); + pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices); + } + + const cash_delta = if (sa.cash) |sc| sc - pf_cash else null; + const total_delta = if (sa.total_value) |st| st - pf_total else null; + + const cash_ok = if (cash_delta) |d| @abs(d) < 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_discrepancy = !cash_ok or !total_ok or portfolio_acct == null, + }); + } + + return results.toOwnedSlice(allocator); +} + +pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void { + try cli.printBold(out, color, "\nSchwab Account Audit", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{}); + try out.print("========================================\n\n", .{}); + + // Column headers + try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>14} {s:>14} {s:>14} {s:>14}\n", .{ + "Account", "PF Cash", "BR Cash", "PF Total", "BR Total", + }); + + var grand_pf: f64 = 0; + var grand_br: f64 = 0; + var discrepancy_count: usize = 0; + + for (results) |r| { + const label = if (r.account_name.len > 0) r.account_name else r.schwab_name; + + var br_cash_buf: [24]u8 = undefined; + var br_total_buf: [24]u8 = undefined; + + const br_cash_str = if (r.schwab_cash) |c| + std.fmt.bufPrint(&br_cash_buf, "{f}", .{Money.from(c)}) catch "$?" + else + "--"; + const br_total_str = if (r.schwab_total) |t| + std.fmt.bufPrint(&br_total_buf, "{f}", .{Money.from(t)}) catch "$?" + else + "--"; + + const cash_ok = if (r.cash_delta) |d| @abs(d) < common.cash_tolerance else true; + const total_ok = if (r.total_delta) |d| @abs(d) < common.value_tolerance else true; + const is_unmapped = r.account_name.len == 0; + const is_real_mismatch = !cash_ok or is_unmapped; + + if (is_real_mismatch) discrepancy_count += 1; + + // Account label + try out.print(" ", .{}); + if (is_unmapped) { + try cli.printFg(out, color, cli.CLR_WARNING, "{s:<24}", .{label}); + } else { + try out.print("{s:<24}", .{label}); + } + + // PF Cash — colored if mismatched (brokerage is truth) + try out.print(" ", .{}); + if (!cash_ok) { + const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; + try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_cash).padRight(14)}); + } else { + try out.print("{f}", .{Money.from(r.portfolio_cash).padRight(14)}); + } + + // BR Cash + try out.print(" {s:>14}", .{br_cash_str}); + + // PF Total — colored if not just stale prices + try out.print(" ", .{}); + if (!total_ok and !cash_ok) { + const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; + try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)}); + } else { + try out.print("{f}", .{Money.from(r.portfolio_total).padRight(14)}); + } + + // BR Total + try out.print(" {s:>14}", .{br_total_str}); + + // Status + if (is_unmapped) { + try cli.printFg(out, color, cli.CLR_WARNING, " Unmapped", .{}); + } else if (!cash_ok) { + const d = r.cash_delta.?; + const sign: []const u8 = if (d >= 0) "+" else "-"; + try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) }); + } else if (!total_ok) { + const d = r.total_delta.?; + const sign: []const u8 = if (d >= 0) "+" else "-"; + try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) }); + } + try out.print("\n", .{}); + + grand_pf += r.portfolio_total; + if (r.schwab_total) |t| grand_br += t; + } + + // Grand totals + try out.print("\n", .{}); + const grand_delta = grand_br - grand_pf; + + try cli.printBold(out, color, " Total: portfolio {f} schwab {f}", .{ + Money.from(grand_pf), + Money.from(grand_br), + }); + + if (@abs(grand_delta) < 1.0) { + // no delta + } else { + const sign: []const u8 = if (grand_delta >= 0) "+" else "-"; + const rgb = if (grand_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; + try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_delta)) }); + } + try out.print("\n", .{}); + + if (discrepancy_count > 0) { + try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} — drill down with: zfin audit --schwab \n", .{ + discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"), + }); + } + try out.print("\n", .{}); +} + +/// Direct-indexing ratio suggestions from Schwab-summary data. +/// +/// The Schwab summary path only gives us per-account totals, not +/// per-symbol detail. For a direct-indexing account with exactly one +/// stock lot (the common case — the account is the proxy basket, +/// tracked as a single benchmark lot), we can still emit a ratio +/// suggestion from the account-level `total_delta`: +/// +/// current_stock_value = portfolio_total - portfolio_cash +/// target_stock_value = current_stock_value + total_delta +/// suggested_ratio = target_stock_value / (shares × price) +/// +/// Where `price` is `shares × current_cached_price`. The math +/// assumes the full account delta lands on the single tracked lot, +/// which is the semantics of a direct-indexing proxy. +/// +/// Skips accounts with more than one stock lot (can't allocate the +/// delta) or zero stock lots (nothing to adjust). +pub fn displaySchwabSummaryRatioSuggestions( + results: []const SchwabAccountComparison, + portfolio: zfin.Portfolio, + prices: std.StringHashMap(f64), + account_map: ?analysis.AccountMap, + color: bool, + out: *std.Io.Writer, +) !void { + const am = account_map orelse return; + var has_header = false; + + for (results) |r| { + if (r.account_name.len == 0) continue; + if (!am.isDirectIndexing(r.account_name)) continue; + const total_delta = r.total_delta orelse continue; + if (@abs(total_delta) < 0.01) continue; + + // Find the single stock lot for this account. + var stock_lot: ?zfin.Lot = null; + var stock_lot_count: usize = 0; + for (portfolio.lots) |lot| { + if (lot.security_type != .stock) continue; + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, r.account_name)) continue; + stock_lot = lot; + stock_lot_count += 1; + } + if (stock_lot_count != 1) continue; + const lot = stock_lot.?; + + const price_sym = lot.priceSymbol(); + const retail_price = prices.get(price_sym) orelse continue; + if (retail_price == 0) continue; + if (lot.shares == 0) continue; + + const current_stock_value = lot.shares * retail_price * lot.price_ratio; + if (current_stock_value == 0) continue; + const target_stock_value = current_stock_value + total_delta; + const suggested_ratio = target_stock_value / (lot.shares * retail_price); + const drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0; + + if (!has_header) { + try out.print("\n", .{}); + try cli.printBold(out, color, " Ratio updates", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf; direct-indexing accounts)\n", .{}); + has_header = true; + } + + var cur_buf: [24]u8 = undefined; + var sug_buf: [24]u8 = undefined; + var drift_buf: [16]u8 = undefined; + const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{lot.price_ratio}) catch "?"; + const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?"; + const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.4}%", .{drift_pct}) catch "?"; + + try out.print(" {s:<16} ", .{lot.symbol}); + try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym}); + try out.print(" ratio {s} -> ", .{cur_str}); + try cli.printBold(out, color, "{s}", .{sug_str}); + try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str}); + } + + if (has_header) try out.print("\n", .{}); +} + +/// 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); +} + +// ── displaySchwabResults rendering ─────────────────────────── + +test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" { + const results = [_]SchwabAccountComparison{ + // clean mapped → no status + .{ .account_name = "Sample Roth", .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 }, + // cash mismatch → "Cash +$5.00", counts as a real mismatch + .{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true }, + // value-only mismatch (cash ok) → muted "Value +$100.00", not a real mismatch + .{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .has_discrepancy = false }, + // unmapped, null broker fields → "Unmapped" + "--", counts as a real mismatch + .{ .account_name = "", .schwab_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true }, + }; + + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displaySchwabResults(&results, false, &w); + const out = w.buffered(); + + try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Sample Roth") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Cash +") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Unmapped") != null); + // unmapped row falls back to the schwab_name label + try std.testing.expect(std.mem.indexOf(u8, out, "Sample Brokerage 3456") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "--") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "schwab") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "delta") != null); + // cash-mismatch + unmapped = 2 real mismatches → plural + try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null); +} + +test "displaySchwabResults: color=true emits ANSI and singular label" { + const results = [_]SchwabAccountComparison{ + .{ .account_name = "", .schwab_name = "Sample Brokerage 9999", .account_number = "9999", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true }, + }; + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displaySchwabResults(&results, true, &w); + const out = w.buffered(); + + try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); + // single mismatch → singular "1 mismatch" label + try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null); +} + +// ── displaySchwabSummaryRatioSuggestions ───────────────────── + +test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot direct-indexing account" { + const allocator = std.testing.allocator; + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SPY", 500.0); + + // total_delta 1000 on a 50000 stock value → suggested ratio 1.02 vs 1.0. + const results = [_]SchwabAccountComparison{ + .{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true }, + }; + + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w); + const out = w.buffered(); + + try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "SPY") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "ratio 1 -> ") != null); +} + +test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" { + const allocator = std.testing.allocator; + const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const results = [_]SchwabAccountComparison{ + .{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true }, + }; + var buf: [512]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, null, false, &w); + try std.testing.expectEqual(@as(usize, 0), w.buffered().len); +} + +test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipped" { + const allocator = std.testing.allocator; + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SPY", 500.0); + + const results = [_]SchwabAccountComparison{ + .{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true }, + }; + var buf: [512]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w); + try std.testing.expectEqual(@as(usize, 0), w.buffered().len); +} diff --git a/src/models/option.zig b/src/models/option.zig index 1b79b5f..3fe9f29 100644 --- a/src/models/option.zig +++ b/src/models/option.zig @@ -1,4 +1,5 @@ const Date = @import("../Date.zig"); +const portfolio = @import("portfolio.zig"); pub const ContractType = enum { call, @@ -50,3 +51,202 @@ pub const OptionsChain = struct { }; const std = @import("std"); + +/// Match a compact broker-display option symbol against a portfolio +/// lot by parsing the symbol's components (underlying, expiration, +/// call/put, strike) and comparing them to the lot's structured +/// fields. +/// +/// Compact format: `[-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}` +/// (e.g. "-AMZN260515C220"). This is the form brokers like Fidelity +/// and Schwab use in their positions exports — distinct from the +/// canonical 21-char OCC symbol with a zero-padded strike. The +/// underlying length is variable, so we scan for the first position +/// where 6 consecutive digits encode a valid date. +/// +/// Lives here (the option model) rather than in any one broker's +/// parser because the audit reconciler applies it across brokers — +/// keeping it broker-neutral lets the shared comparison engine in +/// `commands/audit/common.zig` stay decoupled from `brokerage/*`. +pub fn symbolMatchesLot(symbol: []const u8, lot: portfolio.Lot) bool { + if (lot.security_type != .option) return false; + + // Strip leading dash (short indicator) + const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol; + + // Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9 + if (sym.len < 9) return false; + + // Scan for the date boundary: first position where 6 consecutive digits + // form a valid YYMMDD (and the character before is a letter). + var i: usize = 1; // underlying is at least 1 char + while (i + 7 < sym.len) : (i += 1) { + // All 6 chars must be digits + if (!std.ascii.isDigit(sym[i]) or + !std.ascii.isDigit(sym[i + 1]) or + !std.ascii.isDigit(sym[i + 2]) or + !std.ascii.isDigit(sym[i + 3]) or + !std.ascii.isDigit(sym[i + 4]) or + !std.ascii.isDigit(sym[i + 5])) + continue; + + // Character after the 6 digits must be C or P + const type_char = sym[i + 6]; + if (type_char != 'C' and type_char != 'P') continue; + + // Parse date components + const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue; + const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue; + const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue; + if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue; + const year = 2000 + yy; + + // Parse components + const underlying = sym[0..i]; + const option_type: portfolio.OptionType = if (type_char == 'P') .put else .call; + const strike_str = sym[i + 7 ..]; + const strike = std.fmt.parseFloat(f64, strike_str) catch continue; + const date = Date.fromYmd(year, mm, dd); + + // Match against lot fields + const lot_underlying = lot.underlying orelse return false; + const lot_maturity = lot.maturity_date orelse return false; + + if (!std.mem.eql(u8, underlying, lot_underlying)) return false; + if (!lot_maturity.eql(date)) return false; + if (option_type != lot.option_type) return false; + if (lot.strike) |ls| { + if (@abs(ls - strike) > 0.01) return false; + } else return false; + + return true; + } + return false; +} + +test "symbolMatchesLot basic call" { + const lot = portfolio.Lot{ + .symbol = "AMZN 05/15/2026 220.00 C", + .security_type = .option, + .underlying = "AMZN", + .strike = 220.0, + .option_type = .call, + .maturity_date = Date.fromYmd(2026, 5, 15), + .shares = -3, + .open_date = Date.fromYmd(2025, 1, 1), + .open_price = 8.75, + }; + + // Compact format with leading dash (short) + try std.testing.expect(symbolMatchesLot("-AMZN260515C220", lot)); + // Without dash + try std.testing.expect(symbolMatchesLot("AMZN260515C220", lot)); + // Wrong underlying + try std.testing.expect(!symbolMatchesLot("-MSFT260515C220", lot)); + // Wrong date + try std.testing.expect(!symbolMatchesLot("-AMZN260615C220", lot)); + // Wrong type + try std.testing.expect(!symbolMatchesLot("-AMZN260515P220", lot)); + // Wrong strike + try std.testing.expect(!symbolMatchesLot("-AMZN260515C230", lot)); + // Non-option lot + const stock_lot = portfolio.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 }; + try std.testing.expect(!symbolMatchesLot("-AMZN260515C220", stock_lot)); +} + +test "symbolMatchesLot put option and decimal strike" { + const lot = portfolio.Lot{ + .symbol = "AAPL 06/20/2026 220.50 P", + .security_type = .option, + .underlying = "AAPL", + .strike = 220.50, + .option_type = .put, + .maturity_date = Date.fromYmd(2026, 6, 20), + .shares = -1, + .open_date = Date.fromYmd(2025, 1, 1), + .open_price = 5.0, + }; + + try std.testing.expect(symbolMatchesLot("-AAPL260620P220.50", lot)); + try std.testing.expect(symbolMatchesLot("AAPL260620P220.50", lot)); + // Call doesn't match put + try std.testing.expect(!symbolMatchesLot("-AAPL260620C220.50", lot)); +} + +test "symbolMatchesLot single-char underlying" { + const lot = portfolio.Lot{ + .symbol = "A 03/20/2026 150.00 C", + .security_type = .option, + .underlying = "A", + .strike = 150.0, + .option_type = .call, + .maturity_date = Date.fromYmd(2026, 3, 20), + .shares = -2, + .open_date = Date.fromYmd(2025, 1, 1), + .open_price = 3.0, + }; + + try std.testing.expect(symbolMatchesLot("-A260320C150", lot)); + try std.testing.expect(!symbolMatchesLot("-A260320P150", lot)); +} + +test "symbolMatchesLot: option lot with null strike never matches" { + // Everything else lines up (underlying/date/type), but a lot with + // no strike can't be a strike match → the `else return false` arm. + const lot = portfolio.Lot{ + .symbol = "AAPL 06/20/2026 C", + .security_type = .option, + .underlying = "AAPL", + .strike = null, + .option_type = .call, + .maturity_date = Date.fromYmd(2026, 6, 20), + .shares = -1, + .open_date = Date.fromYmd(2025, 1, 1), + .open_price = 5.0, + }; + try std.testing.expect(!symbolMatchesLot("-AAPL260620C220", lot)); +} + +test "symbolMatchesLot: symbol with no valid date boundary falls through to false" { + // Long enough to enter the scan loop, but no 6-digit run followed + // by C/P → the loop exhausts and returns false. + const lot = portfolio.Lot{ + .symbol = "AAPL 06/20/2026 220.00 C", + .security_type = .option, + .underlying = "AAPL", + .strike = 220.0, + .option_type = .call, + .maturity_date = Date.fromYmd(2026, 6, 20), + .shares = -1, + .open_date = Date.fromYmd(2025, 1, 1), + .open_price = 5.0, + }; + try std.testing.expect(!symbolMatchesLot("NODATEHERE", lot)); +} + +test "OptionsChain.deinit and freeSlice free owned memory" { + const allocator = std.testing.allocator; + + // Single-chain deinit. + { + const chain = OptionsChain{ + .underlying_symbol = try allocator.dupe(u8, "AAPL"), + .expiration = Date.fromYmd(2026, 6, 20), + .calls = try allocator.alloc(OptionContract, 0), + .puts = try allocator.alloc(OptionContract, 0), + }; + chain.deinit(allocator); + } + + // Slice freeSlice (deinits each element first). + { + const chains = try allocator.alloc(OptionsChain, 1); + chains[0] = .{ + .underlying_symbol = try allocator.dupe(u8, "MSFT"), + .expiration = Date.fromYmd(2026, 6, 20), + .calls = try allocator.alloc(OptionContract, 0), + .puts = try allocator.alloc(OptionContract, 0), + }; + OptionsChain.freeSlice(allocator, chains); + } +} diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 8afb7ee..41ad530 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -41,7 +41,7 @@ const Candle = @import("candle.zig").Candle; // // See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods // on `Position` for the canonical implementation. All callers in -// snapshot.zig, audit.zig, and valuation.zig route through these — do +// snapshot.zig, audit/, and valuation.zig route through these — do // not reintroduce inline `price * price_ratio` expressions. // // ## Caching pre-multiply pattern @@ -49,7 +49,7 @@ const Candle = @import("candle.zig").Candle; // When manual overrides (2b) get folded into a shared `prices` map // keyed by symbol, they're PRE-MULTIPLIED by `price_ratio` at insert // time (see `commands/snapshot.zig:buildSnapshot` and -// `commands/audit.zig`). This normalizes the cached value so later +// `commands/audit/`). This normalizes the cached value so later // readers can treat every entry uniformly as "price in whichever terms // the lot needs." The `manual_set` (from `buildFallbackPrices`) then // tells readers which entries are preadjusted. @@ -66,7 +66,7 @@ const Candle = @import("candle.zig").Candle; // ── Money-market / stable-NAV classification ──────────────── // -// Centralized so that audit.zig, the Fidelity/Schwab parsers, and the +// Centralized so that audit/, the Fidelity/Schwab parsers, and the // planned snapshot writer all agree on which symbols are fixed-$1-NAV // instruments. Prior to this the classification lived in three places // with three different heuristics that disagreed on edge cases.