//! Schwab reconcilers (pure compute). //! //! Schwab has two export shapes, so this module carries more than //! the Fidelity one: //! //! 1. **Per-account positions CSV** (`--schwab`) - same per-account //! positions shape Fidelity uses, so it feeds the shared //! `common.compareAccounts` engine via `reconcileCsv`. //! 2. **Account summary paste** (`--schwab-summary`) - a //! per-account totals-only view with no per-symbol detail. This //! is the one genuinely broker-specific reconciler, with its own //! comparison type (`SchwabAccountComparison`) and comparator //! (`compareSchwabSummary`). //! //! Parsing lives in `brokerage/schwab.zig`; the ANSI renderers live in //! `commands/audit/schwab.zig`. const std = @import("std"); const zfin = @import("../../root.zig"); const analysis = @import("../../analytics/analysis.zig"); const Date = @import("../../Date.zig"); const common = @import("common.zig"); const schwab_parser = @import("../../brokerage/schwab.zig"); const AccountSummary = schwab_parser.AccountSummary; /// Account-level comparison result for Schwab summary audit. pub const SchwabAccountComparison = struct { account_name: []const u8, schwab_name: []const u8, account_number: []const u8, portfolio_cash: f64, schwab_cash: ?f64, cash_delta: ?f64, portfolio_total: f64, schwab_total: ?f64, total_delta: ?f64, /// Account holds open options: zfin values them at cost, the broker /// at market, so a totals-level value delta is expected (mute it). has_options: bool = false, /// Bounded band within which a value delta is an expected CD mark. cd_allowance: f64 = 0, has_discrepancy: bool, }; // ── Per-account positions CSV (--schwab) ───────────────────── /// Parse a Schwab per-account positions CSV and reconcile it against /// the portfolio via the shared engine. Returns owned /// `AccountComparison` results (free each `.comparisons` slice, then /// the results slice). String fields borrow from `csv_data`, which /// must outlive them. Propagates parser and allocation errors. pub fn reconcileCsv( allocator: std.mem.Allocator, portfolio: zfin.Portfolio, csv_data: []const u8, account_map: analysis.AccountMap, prices: std.StringHashMap(f64), as_of: Date, ) ![]common.AccountComparison { const parsed = try schwab_parser.parseCsv(allocator, csv_data); // Result strings borrow from `csv_data`, not the positions slice, // so freeing the slice array here is safe. defer allocator.free(parsed.positions); return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of); } // ── Account summary paste (--schwab-summary) ───────────────── /// Parse a Schwab account summary and reconcile its per-account /// totals against portfolio.srf. Returns owned results (free the /// slice). String fields borrow from `summary_data`, which must /// outlive them. Propagates parser (`NoAccountsFound`) and /// allocation errors. pub fn reconcileSummary( allocator: std.mem.Allocator, portfolio: zfin.Portfolio, summary_data: []const u8, account_map: analysis.AccountMap, prices: std.StringHashMap(f64), as_of: Date, ) ![]SchwabAccountComparison { const schwab_accounts = try schwab_parser.parseSummary(allocator, summary_data); defer allocator.free(schwab_accounts); return compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of); } /// Compare Schwab summary against portfolio.srf account totals. pub fn compareSchwabSummary( allocator: std.mem.Allocator, portfolio: zfin.Portfolio, schwab_accounts: []const AccountSummary, account_map: analysis.AccountMap, prices: std.StringHashMap(f64), as_of: Date, ) ![]SchwabAccountComparison { var results = std.ArrayList(SchwabAccountComparison).empty; errdefer results.deinit(allocator); for (schwab_accounts) |sa| { const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number); var pf_cash: f64 = 0; var pf_total: f64 = 0; var expectation: common.AccountValueExpectation = .{}; if (portfolio_acct) |pa| { pf_cash = portfolio.cashForAccount(pa); pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices); expectation = common.accountValueExpectation(portfolio, as_of, pa); } const cash_delta = if (sa.cash) |sc| sc - pf_cash else null; const total_delta = if (sa.total_value) |st| st - pf_total else null; const cash_ok = if (cash_delta) |d| @abs(d) < common.cash_tolerance else true; const total_ok = if (total_delta) |d| @abs(d) < common.value_tolerance else true; try results.append(allocator, .{ .account_name = portfolio_acct orelse "", .schwab_name = sa.account_name, .account_number = sa.account_number, .portfolio_cash = pf_cash, .schwab_cash = sa.cash, .cash_delta = cash_delta, .portfolio_total = pf_total, .schwab_total = sa.total_value, .total_delta = total_delta, .has_options = expectation.has_options, .cd_allowance = expectation.cd_allowance, // A value delta is a discrepancy regardless of cause: an // audit must never hide it. Muting (below) is a display // hint, not a suppression - so visibility stays keyed on the // raw tolerance, same as before. .has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null, }); } return results.toOwnedSlice(allocator); } /// Check if any Schwab summary results have discrepancies. pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool { for (results) |r| { if (r.has_discrepancy) return true; } return false; } /// Direct-indexing ratio suggestions from Schwab-summary totals. /// /// The summary path gives per-account totals only, so for a /// direct-indexing account with exactly one stock lot we allocate the /// whole account delta onto that lot: /// /// current_stock_value = shares * retail_price * current_ratio /// target_stock_value = current_stock_value + total_delta /// suggested_ratio = target_stock_value / (shares * retail_price) /// /// Skips non-direct-indexing accounts, unmapped accounts, sub-cent /// deltas, accounts without exactly one stock lot, and zero /// share/price/value. Caller owns the returned slice; strings borrow /// from the inputs. pub fn summaryRatioSuggestions( allocator: std.mem.Allocator, results: []const SchwabAccountComparison, portfolio: zfin.Portfolio, prices: std.StringHashMap(f64), account_map: ?analysis.AccountMap, ) ![]common.RatioSuggestion { var out: std.ArrayList(common.RatioSuggestion) = .empty; errdefer out.deinit(allocator); const am = account_map orelse return out.toOwnedSlice(allocator); 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); try out.append(allocator, .{ .account_name = r.account_name, .lot_symbol = lot.symbol, .price_symbol = price_sym, .current_ratio = lot.price_ratio, .suggested_ratio = suggested_ratio, .drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0, }); } return out.toOwnedSlice(allocator); } // ── Tests ──────────────────────────────────────────────────── const portfolio_mod = @import("../../models/portfolio.zig"); test "hasSchwabDiscrepancies" { const clean = [_]SchwabAccountComparison{.{ .account_name = "IRA", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false, }}; try std.testing.expect(!hasSchwabDiscrepancies(&clean)); const dirty = [_]SchwabAccountComparison{.{ .account_name = "IRA", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 200, .cash_delta = 100, .portfolio_total = 5000, .schwab_total = 5100, .total_delta = 100, .has_discrepancy = true, }}; try std.testing.expect(hasSchwabDiscrepancies(&dirty)); } test "compareSchwabSummary: matching account -> no discrepancy" { const allocator = std.testing.allocator; const today = Date.fromYmd(2026, 5, 8); // Portfolio: $5000 cash + 10 AAPL @ open_price 150 = $1500 cost basis. // With AAPL price=200, total = 5000 + 10*200 = 7000. const lots = [_]portfolio_mod.Lot{ .{ .symbol = "CASH", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage", }, .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample Brokerage", }, }; const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; const schwab_accounts = [_]AccountSummary{ .{ .account_name = "Sample Brokerage", .account_number = "1234", .cash = 5000.0, .total_value = 7000.0, }, }; var entries = [_]analysis.AccountTaxEntry{ .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", }, }; const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("AAPL", 200.0); const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today); defer allocator.free(results); try std.testing.expectEqual(@as(usize, 1), results.len); try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name); try std.testing.expectApproxEqAbs(@as(f64, 5000), results[0].portfolio_cash, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 7000), results[0].portfolio_total, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].cash_delta.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01); try std.testing.expect(!results[0].has_discrepancy); } test "compareSchwabSummary: cash mismatch -> has_discrepancy true" { const allocator = std.testing.allocator; const today = Date.fromYmd(2026, 5, 8); // Portfolio cash = 5000, Schwab reports 5500 -> $500 delta. const lots = [_]portfolio_mod.Lot{ .{ .symbol = "CASH", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Brokerage", }, }; const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; const schwab_accounts = [_]AccountSummary{ .{ .account_name = "Brokerage", .account_number = "1234", .cash = 5500.0, .total_value = 5500.0, }, }; var entries = [_]analysis.AccountTaxEntry{ .{ .account = "Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", }, }; const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today); defer allocator.free(results); try std.testing.expectEqual(@as(usize, 1), results.len); try std.testing.expectApproxEqAbs(@as(f64, 500), results[0].cash_delta.?, 0.01); try std.testing.expect(results[0].has_discrepancy); } test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to the penny)" { const allocator = std.testing.allocator; const today = Date.fromYmd(2026, 6, 19); // Portfolio cash $38.75; Schwab reports $38.97 - a $0.22 accrual. // Below the $1 securities tolerance, but a real cash drift that // must surface. const lots = [_]portfolio_mod.Lot{ .{ .symbol = "CASH", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, }; const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; const schwab_accounts = [_]AccountSummary{ .{ .account_name = "Sample Brokerage", .account_number = "1234", .cash = 38.97, .total_value = 38.97 }, }; var entries = [_]analysis.AccountTaxEntry{ .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" }, }; const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today); defer allocator.free(results); try std.testing.expectEqual(@as(usize, 1), results.len); try std.testing.expectApproxEqAbs(@as(f64, 0.22), results[0].cash_delta.?, 0.001); try std.testing.expect(results[0].has_discrepancy); } test "compareSchwabSummary: account_number with no match -> empty account_name" { const allocator = std.testing.allocator; const today = Date.fromYmd(2026, 5, 8); const lots = [_]portfolio_mod.Lot{}; const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; const schwab_accounts = [_]AccountSummary{ .{ .account_name = "Unknown Acct", .account_number = "9999", .cash = 1000.0, .total_value = 1000.0, }, }; var entries = [_]analysis.AccountTaxEntry{}; const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today); defer allocator.free(results); try std.testing.expectEqual(@as(usize, 1), results.len); try std.testing.expectEqualStrings("", results[0].account_name); try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name); // No portfolio match -> cash and total are zero, schwab values become deltas try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01); } test "compareSchwabSummary: null cash/total fields produce null deltas (within tolerance)" { const allocator = std.testing.allocator; const today = Date.fromYmd(2026, 5, 8); const lots = [_]portfolio_mod.Lot{ .{ .symbol = "CASH", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "X", }, }; const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; // Schwab summary missing cash + total fields (.cash = null, .total_value = null). const schwab_accounts = [_]AccountSummary{ .{ .account_name = "X", .account_number = "1234", .cash = null, .total_value = null, }, }; var entries = [_]analysis.AccountTaxEntry{ .{ .account = "X", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", }, }; const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today); defer allocator.free(results); try std.testing.expectEqual(@as(usize, 1), results.len); try std.testing.expect(results[0].cash_delta == null); try std.testing.expect(results[0].total_delta == null); // Null deltas are treated as "ok" (no discrepancy possible to assert). try std.testing.expect(!results[0].has_discrepancy); } test "compareSchwabSummary: today affects valuation of held assets" { const allocator = std.testing.allocator; // Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before // open), it's not held -> portfolio_total excludes it. With // today=2025-01-01 (after open), portfolio_total includes 10 * price. const lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 150, .account = "Acct", }, }; const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator }; const schwab_accounts = [_]AccountSummary{ .{ .account_name = "Acct", .account_number = "1234", .cash = 0, .total_value = 2000, }, }; var entries = [_]analysis.AccountTaxEntry{ .{ .account = "Acct", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", }, }; const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("AAPL", 200.0); // Before open: portfolio holds nothing for this account. { const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2024, 1, 1)); defer allocator.free(results); try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_total, 0.01); } // After open: portfolio holds 10 * 200 = 2000. { const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1)); defer allocator.free(results); try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01); // Matches schwab -> no discrepancy. try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01); try std.testing.expect(!results[0].has_discrepancy); } } // ── reconcile wrappers (parse + compare wiring) ────────────── test "reconcileCsv: parses a Schwab positions CSV and reconciles it" { const allocator = std.testing.allocator; const csv = "\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"\n" ++ "\n" ++ "\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++ "\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n"; var lots = [_]portfolio_mod.Lot{ .{ .symbol = "AMZN", .shares = 1488, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 74, .account = "Sample Trust" }, }; const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; var entries = [_]analysis.AccountTaxEntry{ .{ .account = "Sample Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" }, }; const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("AMZN", 239.208); const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 4, 10)); defer { for (results) |r| allocator.free(r.comparisons); allocator.free(results); } try std.testing.expectEqual(@as(usize, 1), results.len); try std.testing.expectEqualStrings("Sample Trust", results[0].account_name); var found_amzn = false; for (results[0].comparisons) |c| { if (std.mem.eql(u8, c.symbol, "AMZN")) found_amzn = true; } try std.testing.expect(found_amzn); } test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" { const allocator = std.testing.allocator; const data = \\Sample Roth \\Account number ending in 1234 ...1234 \\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47% \\Sample Inherited IRA \\Account number ending in 5678 ...5678 \\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73% ; const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator }; var entries = [_]analysis.AccountTaxEntry{ .{ .account = "Sample Roth IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" }, }; const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const results = try reconcileSummary(allocator, portfolio, data, acct_map, prices, Date.fromYmd(2026, 4, 10)); defer allocator.free(results); try std.testing.expectEqual(@as(usize, 2), results.len); // 1234 maps; 5678 is absent from the map -> unmapped (empty name). try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name); try std.testing.expectEqualStrings("", results[1].account_name); } test "compareSchwabSummary: populates option flag and CD allowance from the portfolio" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 28); var lots = [_]portfolio_mod.Lot{ // CD >1yr out -> caps at one year's coupon: 4% of 50000 = 2000. .{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" }, // Open option -> has_options. .{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" }, }; const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; var entries = [_]analysis.AccountTaxEntry{ .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" }, }; const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); var summary = [_]AccountSummary{ .{ .account_name = "IRA", .account_number = "1234", .cash = 0, .total_value = 60000 }, }; const results = try compareSchwabSummary(allocator, portfolio, &summary, acct_map, prices, as_of); defer allocator.free(results); try std.testing.expectEqual(@as(usize, 1), results.len); try std.testing.expect(results[0].has_options); try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].cd_allowance, 0.01); } // ── summaryRatioSuggestions ────────────────────────────────── test "summaryRatioSuggestions: single-lot direct-indexing yields exact ratio + drift" { 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 am = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SPY", 500.0); // current = 100*500*1.0 = 50000; target = 51000; suggested = 1.02, +2%. 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 }, }; const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am); defer allocator.free(sugg); try std.testing.expectEqual(@as(usize, 1), sugg.len); try std.testing.expectEqualStrings("SPY", sugg[0].lot_symbol); try std.testing.expectApproxEqAbs(@as(f64, 1.0), sugg[0].current_ratio, 1e-9); try std.testing.expectApproxEqAbs(@as(f64, 1.02), sugg[0].suggested_ratio, 1e-9); try std.testing.expectApproxEqAbs(@as(f64, 2.0), sugg[0].drift_pct, 1e-9); } test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all skip" { 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 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 }, }; // null account_map -> empty. { const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, null); defer allocator.free(sugg); try std.testing.expectEqual(@as(usize, 0), sugg.len); } // account present but NOT direct-indexing -> skipped. { var entries = [_]analysis.AccountTaxEntry{ .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false }, }; const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am); defer allocator.free(sugg); try std.testing.expectEqual(@as(usize, 0), sugg.len); } // direct-indexing but sub-cent delta -> skipped. { var entries = [_]analysis.AccountTaxEntry{ .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true }, }; const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; const tiny = [_]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 = 50000.005, .total_delta = 0.005, .has_discrepancy = false }, }; const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am); defer allocator.free(sugg); try std.testing.expectEqual(@as(usize, 0), sugg.len); } // direct-indexing but two stock lots -> can't allocate the delta -> skipped. { var two = [_]portfolio_mod.Lot{ .{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 }, .{ .symbol = "QQQ", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 }, }; const pf2 = portfolio_mod.Portfolio{ .lots = &two, .allocator = allocator }; var entries = [_]analysis.AccountTaxEntry{ .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true }, }; const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; const sugg = try summaryRatioSuggestions(allocator, &results, pf2, prices, am); defer allocator.free(sugg); try std.testing.expectEqual(@as(usize, 0), sugg.len); } }