From 00482da00f79e6a74bfc8b4ff6b2d8307fe0cfef Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 28 Jun 2026 13:21:56 -0700 Subject: [PATCH] make value discrepancies warnings if we can detect it is likely a problem --- src/commands/audit/common.zig | 250 +++++++++++++++++++++++++++++++++- src/commands/audit/schwab.zig | 113 ++++++++++++++- 2 files changed, 355 insertions(+), 8 deletions(-) diff --git a/src/commands/audit/common.zig b/src/commands/audit/common.zig index 1e861f8..fedf776 100644 --- a/src/commands/audit/common.zig +++ b/src/commands/audit/common.zig @@ -39,6 +39,73 @@ const BrokeragePosition = brokerage_types.BrokeragePosition; pub const value_tolerance: f64 = 1.0; pub const cash_tolerance: f64 = 0.01; +/// A CD's reconciliation allowance is capped at one year's coupon: +/// years-to-maturity is clamped to 1.0 so a long-dated CD doesn't open +/// an unbounded muting window. (Near-maturity CDs - the common case - +/// get a tight band; e.g. a $87k 3.8% CD 17 days out -> ~$154.) +pub const cd_allowance_year_cap: f64 = 1.0; +/// Fallback band (percent of face) for a CD that carries no `rate`. +/// Rare, but we still want a non-zero allowance so a rate-less CD isn't +/// flagged on every routine dealer mark. +pub const cd_allowance_fallback_pct: f64 = 0.5; + +/// Expected secondary-market mark on a single open CD, in dollars. +/// +/// zfin values a CD at face (`shares`); the broker marks it to the +/// secondary market, which drifts from face by a small, bounded amount +/// driven by the remaining coupon and time to maturity. We can't +/// reproduce the broker's exact mark without a live yield, but we can +/// bound it: `face x (rate/100) x years_to_maturity` (years capped at +/// 1.0). A value delta within this band is an expected CD mark (mute); +/// beyond it, something else is off (warn). Returns 0 for non-CD lots. +pub fn cdLotAllowance(lot: portfolio_mod.Lot, as_of: Date) f64 { + if (lot.security_type != .cd) return 0; + const face = @abs(lot.shares); + const rate = lot.rate orelse return face * (cd_allowance_fallback_pct / 100.0); + const years: f64 = if (lot.maturity_date) |m| blk: { + const y = Date.yearsBetween(as_of, m); + break :blk if (y < 0) 0 else @min(y, cd_allowance_year_cap); + } else cd_allowance_year_cap; + return face * (rate / 100.0) * years; +} + +/// Per-account, broker-agnostic classifier for "expected" value +/// differences between zfin and a brokerage. Reconcilers use it to +/// decide whether an account-level value delta should be muted +/// (expected) or warned (real discrepancy). +/// +/// - `has_options`: the account holds >= 1 open option lot. zfin +/// values options at cost (premium) while brokers mark them to +/// market, so the gap is real but unbounded and unknowable without +/// a live option quote. At the totals-only summary level any value +/// delta is therefore "expected" - mute it. (The per-position path +/// carves options out symbol-by-symbol instead; this is the +/// totals-only fallback.) +/// - `cd_allowance`: summed `cdLotAllowance` over the account's open +/// CDs - the bounded band within which a value delta is explained +/// by CD dealer marks. +pub const AccountValueExpectation = struct { + has_options: bool = false, + cd_allowance: f64 = 0, +}; + +/// Walk an account's open lots and summarize its expected value +/// differences. See `AccountValueExpectation`. +pub fn accountValueExpectation(portfolio: zfin.Portfolio, as_of: Date, account_name: []const u8) AccountValueExpectation { + var result: AccountValueExpectation = .{}; + for (portfolio.lots) |lot| { + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, account_name)) continue; + if (!lot.isOpen(as_of)) continue; + switch (lot.security_type) { + .option => result.has_options = true, + .cd => result.cd_allowance += cdLotAllowance(lot, as_of), + else => {}, + } + } + return result; +} + /// Resolved position value for audit display: effective per-share price /// and total market value, with correct `price_ratio` handling based on /// the price's provenance. @@ -84,6 +151,12 @@ pub const SymbolComparison = struct { value_delta: ?f64, is_cash: bool, is_option: bool, + /// True when the matched portfolio lot(s) are a CD. A CD's value + /// delta is muted up to `cd_allowance`; beyond that it warns. + is_cd: bool = false, + /// Summed `cdLotAllowance` for the matched CD lot(s). Zero unless + /// `is_cd`. + cd_allowance: f64 = 0, only_in_brokerage: bool, only_in_portfolio: bool, }; @@ -269,6 +342,8 @@ pub fn compareAccounts( var pf_value: f64 = 0; var pf_price: ?f64 = null; var is_option = false; + var is_cd = false; + var cd_allowance: f64 = 0; if (bp.is_cash) { pf_shares = portfolio.cashForAccount(portfolio_acct_name.?); @@ -306,6 +381,8 @@ pub fn compareAccounts( pf_shares += lot.shares; pf_value += lot.shares; pf_price = 1.0; + is_cd = true; + cd_allowance += cdLotAllowance(lot, as_of); }, .option => { pf_shares += lot.shares; @@ -356,6 +433,8 @@ pub fn compareAccounts( .value_delta = value_delta, .is_cash = bp.is_cash, .is_option = is_option, + .is_cd = is_cd, + .cd_allowance = cd_allowance, .only_in_brokerage = pf_shares == 0 and pf_value == 0, .only_in_portfolio = false, }); @@ -446,6 +525,7 @@ pub fn compareAccounts( .value_delta = null, .is_cash = is_cd, .is_option = !is_cd, + .is_cd = is_cd, .only_in_brokerage = false, .only_in_portfolio = true, }); @@ -604,7 +684,13 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std // 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; + // A CD's value delta is an expected dealer mark up to its + // band; only a delta beyond what its terms can explain is a + // real discrepancy. Stale stock prices (shares match, no CD) + // stay muted, as before. + const cd_band = @max(value_tolerance, cmp.cd_allowance); + const cd_value_exceeded = cmp.is_cd and (if (cmp.value_delta) |d| @abs(d) > cd_band else false); + const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch or cd_value_exceeded; if (is_real_mismatch) discrepancy_count += 1; @@ -1365,6 +1451,134 @@ test "compareAccounts: a broker CD row matches a portfolio CD lot" { try std.testing.expect(!results[0].has_discrepancies); } +test "cdLotAllowance: near-maturity CD yields a tight band" { + const as_of = Date.fromYmd(2026, 6, 28); + const cd = portfolio_mod.Lot{ + .symbol = "CDNEAR", + .security_type = .cd, + .shares = 87000, + .open_date = Date.fromYmd(2026, 2, 25), + .open_price = 1.0, + .rate = 3.8, + .maturity_date = Date.fromYmd(2026, 7, 15), + .account = "Sample IRA", + }; + // 17 days out: 87000 * 3.8% * (17/365.25) ~= $154. Tight enough to + // mute a dealer mark, far below a missing lot. + const band = cdLotAllowance(cd, as_of); + try std.testing.expect(band > 100 and band < 200); +} + +test "cdLotAllowance: long CD is capped at one year's coupon" { + const as_of = Date.fromYmd(2026, 6, 28); + const cd = portfolio_mod.Lot{ + .symbol = "CD5YR", + .security_type = .cd, + .shares = 100000, + .open_date = Date.fromYmd(2026, 1, 1), + .open_price = 1.0, + .rate = 4.0, + .maturity_date = Date.fromYmd(2031, 6, 28), + .account = "Sample IRA", + }; + // ~5 years out, but years clamp to 1.0 -> 100000 * 4% * 1.0 = 4000. + try std.testing.expectApproxEqAbs(@as(f64, 4000), cdLotAllowance(cd, as_of), 0.01); +} + +test "cdLotAllowance: rate-less CD falls back to a small percent of face" { + const as_of = Date.fromYmd(2026, 6, 28); + const cd = portfolio_mod.Lot{ + .symbol = "CDNORATE", + .security_type = .cd, + .shares = 10000, + .open_date = Date.fromYmd(2026, 1, 1), + .open_price = 1.0, + .maturity_date = Date.fromYmd(2026, 12, 31), + .account = "Sample IRA", + }; + // No rate -> 0.5% of $10,000 = $50. + try std.testing.expectApproxEqAbs(@as(f64, 50), cdLotAllowance(cd, as_of), 0.01); +} + +test "cdLotAllowance: matured CD and non-CD lots yield zero" { + const as_of = Date.fromYmd(2026, 6, 28); + // Maturity already passed -> years clamp to 0 -> no band. + const matured = portfolio_mod.Lot{ + .symbol = "CDOLD", + .security_type = .cd, + .shares = 50000, + .open_date = Date.fromYmd(2024, 1, 1), + .open_price = 1.0, + .rate = 5.0, + .maturity_date = Date.fromYmd(2026, 1, 1), + .account = "Sample IRA", + }; + try std.testing.expectApproxEqAbs(@as(f64, 0), cdLotAllowance(matured, as_of), 0.01); + + const stock = portfolio_mod.Lot{ + .symbol = "AAPL", + .shares = 10, + .open_date = Date.fromYmd(2024, 1, 1), + .open_price = 150, + .account = "Sample Brokerage", + }; + try std.testing.expectEqual(@as(f64, 0), cdLotAllowance(stock, as_of)); +} + +test "accountValueExpectation: CDs sum into a band, an open option flips the flag" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 28); + var lots = [_]portfolio_mod.Lot{ + // CD in Sample IRA, >1yr out so it caps at one year's coupon: 4% of 50000 = 2000. + .{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" }, + // Open option in Sample IRA -> has_options. + .{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" }, + // Plain stock in a different account. + .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample Brokerage" }, + // Matured CD in Sample IRA must be excluded (not open as-of). + .{ .symbol = "CDMAT", .security_type = .cd, .shares = 99999, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .rate = 9.0, .maturity_date = Date.fromYmd(2026, 1, 1), .account = "Sample IRA" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + const ira = accountValueExpectation(portfolio, as_of, "Sample IRA"); + try std.testing.expect(ira.has_options); + try std.testing.expectApproxEqAbs(@as(f64, 2000), ira.cd_allowance, 0.01); + + const brk = accountValueExpectation(portfolio, as_of, "Sample Brokerage"); + try std.testing.expect(!brk.has_options); + try std.testing.expectEqual(@as(f64, 0), brk.cd_allowance); +} + +test "compareAccounts: a CD's value delta carries is_cd and the bounded allowance" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 28); + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "CDX", .security_type = .cd, .shares = 100000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2026, 9, 1), .account = "Sample IRA" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + // Broker marks the CD $40 under face (~2mo out -> band is hundreds of $). + var brokerage = [_]BrokeragePosition{ + .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "CDX", .description = "BANK CD", .quantity = 100000, .current_value = 99960, .cost_basis = 100000, .is_cash = false }, + }; + const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, as_of); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len); + const cmp = results[0].comparisons[0]; + try std.testing.expect(cmp.is_cd); + try std.testing.expect(cmp.cd_allowance > 200); // the $40 mark sits well within + try std.testing.expectApproxEqAbs(@as(f64, -40), cmp.value_delta.?, 0.01); +} + // ── displayResults rendering ───────────────────────────────── test "displayResults: renders every row classification and the totals block" { @@ -1444,6 +1658,40 @@ test "displayResults: color=true emits ANSI and singular mismatch label" { try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null); } +test "displayResults: CD value delta within band is muted+uncounted, beyond band warns+counts" { + // Within band: is_cd, |value_delta| <= cd_allowance -> shown but + // muted, and not counted in "to investigate". + { + const cmps = [_]SymbolComparison{ + .{ .symbol = "CDX", .portfolio_shares = 100000, .brokerage_shares = 100000, .portfolio_price = 1.0, .brokerage_price = 0.9996, .portfolio_value = 100000, .brokerage_value = 99960, .shares_delta = 0, .value_delta = -40, .is_cash = false, .is_option = false, .is_cd = true, .cd_allowance = 680, .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 = 100000, .brokerage_total = 99960, .total_delta = -40, .option_value_delta = 0, .has_discrepancies = true }, + }; + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displayResults(&results, false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); // shown, never hidden + try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null); // not counted + } + // Beyond band: the same CD marked $5,000 under face -> warn + count. + { + const cmps = [_]SymbolComparison{ + .{ .symbol = "CDX", .portfolio_shares = 100000, .brokerage_shares = 100000, .portfolio_price = 1.0, .brokerage_price = 0.95, .portfolio_value = 100000, .brokerage_value = 95000, .shares_delta = 0, .value_delta = -5000, .is_cash = false, .is_option = false, .is_cd = true, .cd_allowance = 680, .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 = 100000, .brokerage_total = 95000, .total_delta = -5000, .option_value_delta = 0, .has_discrepancies = true }, + }; + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displayResults(&results, false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null); // counted + } +} + // ── displayRatioSuggestions ────────────────────────────────── test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" { diff --git a/src/commands/audit/schwab.zig b/src/commands/audit/schwab.zig index 683815a..e0a3027 100644 --- a/src/commands/audit/schwab.zig +++ b/src/commands/audit/schwab.zig @@ -36,6 +36,11 @@ pub const SchwabAccountComparison = struct { 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, }; @@ -98,10 +103,12 @@ pub fn compareSchwabSummary( 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; @@ -120,6 +127,12 @@ pub fn compareSchwabSummary( .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, }); } @@ -157,9 +170,15 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo "--"; 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; + // A value delta is "exact" (no note) under $1, "expected" (muted) + // when the account holds options or the delta fits the CD band, + // and a real "warn" otherwise. + const band = @max(common.value_tolerance, r.cd_allowance); + const value_exact = if (r.total_delta) |d| @abs(d) < common.value_tolerance else true; + const value_expected = r.has_options or (if (r.total_delta) |d| @abs(d) <= band else true); + const value_warn = !value_exact and !value_expected; const is_unmapped = r.account_name.len == 0; - const is_real_mismatch = !cash_ok or is_unmapped; + const is_real_mismatch = !cash_ok or is_unmapped or value_warn; if (is_real_mismatch) discrepancy_count += 1; @@ -183,9 +202,9 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo // BR Cash try out.print(" {s:>14}", .{br_cash_str}); - // PF Total - colored if not just stale prices + // PF Total - colored only on a real (beyond-allowance) value mismatch try out.print(" ", .{}); - if (!total_ok and !cash_ok) { + if (value_warn) { 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 { @@ -202,7 +221,13 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo 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) { + } else if (value_warn) { + const d = r.total_delta.?; + const sign: []const u8 = if (d >= 0) "+" else "-"; + try cli.printFg(out, color, cli.CLR_WARNING, " Value {s}{f}", .{ sign, Money.from(@abs(d)) }); + } else if (!value_exact) { + // Expected difference (CD mark or options present): shown so + // nothing is hidden, but muted - you probably don't care. 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)) }); @@ -709,6 +734,34 @@ test "reconcileSummary: parses a Schwab summary paste and reconciles per-account try std.testing.expectEqualStrings("", results[1].account_name); } +test "compareSchwabSummary: populates option flag and CD allowance from the portfolio" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 6, 28); + var lots = [_]portfolio_mod.Lot{ + // CD >1yr out -> caps at one year's coupon: 4% of 50000 = 2000. + .{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" }, + // Open option -> has_options. + .{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + var summary = [_]AccountSummary{ + .{ .account_name = "IRA", .account_number = "1234", .cash = 0, .total_value = 60000 }, + }; + + const results = try compareSchwabSummary(allocator, portfolio, &summary, acct_map, prices, as_of); + defer allocator.free(results); + + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expect(results[0].has_options); + try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].cd_allowance, 0.01); +} + // ── displaySchwabResults rendering ─────────────────────────── test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" { @@ -717,8 +770,9 @@ test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" .{ .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 }, + // value delta within the CD band (cash ok) -> muted "Value + // +$100.00", shown but not counted as 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, .cd_allowance = 200, .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 }, }; @@ -758,6 +812,51 @@ test "displaySchwabResults: color=true emits ANSI and singular label" { try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null); } +test "displaySchwabResults: value delta with no CD or options warns and counts" { + // The Kelly-IRA shape: cash matches to the penny, but the total is + // off (a missing lot). No CD, no options -> a real, counted warning. + const results = [_]SchwabAccountComparison{ + .{ .account_name = "Sample IRA", .schwab_name = "IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 250000, .schwab_total = 251740.29, .total_delta = 1740.29, .has_discrepancy = true }, + }; + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displaySchwabResults(&results, true, &w); + const out = w.buffered(); + + var seqbuf: [32]u8 = undefined; + const warn_seq = try std.fmt.bufPrint(&seqbuf, "\x1b[38;2;{d};{d};{d}m", .{ cli.CLR_WARNING[0], cli.CLR_WARNING[1], cli.CLR_WARNING[2] }); + + try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null); // shown + try std.testing.expect(std.mem.indexOf(u8, out, warn_seq) != null); // warning-colored + try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null); // counted +} + +test "displaySchwabResults: CD-band and option value deltas are muted, shown, and uncounted" { + // muted != hidden: both rows show their "Value" delta, but neither + // is warning-colored and neither counts as a mismatch. + const results = [_]SchwabAccountComparison{ + // CD account: $8.70 mark sits within a $154 band -> expected/muted. + .{ .account_name = "Sample IRA", .schwab_name = "IRA", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 100000, .schwab_total = 99991.30, .total_delta = -8.70, .cd_allowance = 154, .has_discrepancy = true }, + // Option account: cost-vs-market gap is unbounded -> muted, drill down. + .{ .account_name = "Sample Roth", .schwab_name = "Roth", .account_number = "5678", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 55000, .total_delta = 5000, .has_options = true, .has_discrepancy = true }, + }; + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try displaySchwabResults(&results, true, &w); + const out = w.buffered(); + + var warnbuf: [32]u8 = undefined; + const warn_seq = try std.fmt.bufPrint(&warnbuf, "\x1b[38;2;{d};{d};{d}m", .{ cli.CLR_WARNING[0], cli.CLR_WARNING[1], cli.CLR_WARNING[2] }); + var mutebuf: [32]u8 = undefined; + const mute_seq = try std.fmt.bufPrint(&mutebuf, "\x1b[38;2;{d};{d};{d}m", .{ cli.CLR_MUTED[0], cli.CLR_MUTED[1], cli.CLR_MUTED[2] }); + + try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); // CD mark shown + try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null); // option gap shown + try std.testing.expect(std.mem.indexOf(u8, out, mute_seq) != null); // muted styling present + try std.testing.expect(std.mem.indexOf(u8, out, warn_seq) == null); // never escalated to warning + try std.testing.expect(std.mem.indexOf(u8, out, "mismatch") == null); // nothing to investigate +} + // ── displaySchwabSummaryRatioSuggestions ───────────────────── test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot direct-indexing account" {