diff --git a/src/analytics/reconcile.zig b/src/analytics/reconcile.zig index 8c0706b..b7e36e7 100644 --- a/src/analytics/reconcile.zig +++ b/src/analytics/reconcile.zig @@ -24,11 +24,14 @@ pub const compareAccounts = common.compareAccounts; pub const hasAccountDiscrepancies = common.hasAccountDiscrepancies; pub const presentNumbers = common.presentNumbers; pub const findAbsentAccounts = common.findAbsentAccounts; +pub const RatioSuggestion = common.RatioSuggestion; +pub const ratioSuggestions = common.ratioSuggestions; pub const SchwabAccountComparison = schwab.SchwabAccountComparison; pub const compareSchwabSummary = schwab.compareSchwabSummary; pub const reconcileCsv = schwab.reconcileCsv; pub const reconcileSummary = schwab.reconcileSummary; pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies; +pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions; pub const reconcileFidelity = fidelity.reconcile; diff --git a/src/analytics/reconcile/common.zig b/src/analytics/reconcile/common.zig index 08e370e..4f17fd8 100644 --- a/src/analytics/reconcile/common.zig +++ b/src/analytics/reconcile/common.zig @@ -646,6 +646,90 @@ pub fn findAbsentAccounts( return results.toOwnedSlice(allocator); } + +// ── Ratio suggestions ──────────────────────────────────────── + +/// A suggested `price_ratio` update for a portfolio.srf lot, derived +/// from the brokerage NAV vs the configured ratio. Pure data; the +/// string fields borrow from `portfolio` / `results`. The audit +/// renderers and finrev both consume these; formatting lives at the +/// call site, not here. +pub const RatioSuggestion = struct { + account_name: []const u8, + lot_symbol: []const u8, + price_symbol: []const u8, + current_ratio: f64, + suggested_ratio: f64, + drift_pct: f64, +}; + +/// Suggest `price_ratio` updates from per-account comparisons. +/// +/// Institutional share class: `suggested = brokerage_NAV / retail_price` +/// against the configured lot ratio. Normally only lots with +/// `price_ratio != 1.0` qualify; direct-indexing accounts (flagged in +/// `accounts.srf`) bypass that gate, since their tracking-error drift is +/// expressed by nudging a ratio that starts at 1.0. +/// +/// Skips unmatched / cash / option rows, non-stock lots, symbol +/// mismatches, missing or zero retail prices, and no-drift lots +/// (`current == suggested`). At most one suggestion per comparison row. +/// Caller owns the returned slice; strings borrow from the inputs. +pub fn ratioSuggestions( + allocator: std.mem.Allocator, + results: []const AccountComparison, + portfolio: zfin.Portfolio, + prices: std.StringHashMap(f64), + account_map: ?analysis.AccountMap, +) ![]RatioSuggestion { + var out: std.ArrayList(RatioSuggestion) = .empty; + errdefer out.deinit(allocator); + + for (results) |acct| { + for (acct.comparisons) |cmp| { + if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue; + if (cmp.is_cash or cmp.is_option) continue; + + const is_direct_indexing = if (account_map) |am| + am.isDirectIndexing(acct.account_name) + else + false; + + 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; + + 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; + + const retail_price = prices.get(price_sym) orelse continue; + 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; + if (current_ratio == suggested_ratio) break; // no drift + + try out.append(allocator, .{ + .account_name = acct.account_name, + .lot_symbol = lot_sym, + .price_symbol = price_sym, + .current_ratio = current_ratio, + .suggested_ratio = suggested_ratio, + .drift_pct = (suggested_ratio - current_ratio) / current_ratio * 100.0, + }); + break; // one suggestion per comparison row + } + } + } + + return out.toOwnedSlice(allocator); +} + // ── Tests ──────────────────────────────────────────────────── test "consolidateBySymbol: distinct symbols pass through unchanged" { @@ -1366,3 +1450,111 @@ test "findAbsentAccounts: no absent accounts when export covers every held accou try std.testing.expectEqual(@as(usize, 0), absent.len); } + +// ── ratioSuggestions ───────────────────────────────────────── + +test "ratioSuggestions: institutional NAV drift yields exact ratio + drift" { + 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{ + // inst NAV 30 / retail 5 = suggested 6.0 vs configured 5.0 + .{ .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 }, + }; + const sugg = try ratioSuggestions(allocator, &results, portfolio, prices, null); + defer allocator.free(sugg); + try std.testing.expectEqual(@as(usize, 1), sugg.len); + try std.testing.expectEqualStrings("VTHRX", sugg[0].lot_symbol); + try std.testing.expectEqualStrings("VTHRX", sugg[0].price_symbol); + try std.testing.expectApproxEqAbs(@as(f64, 5.0), sugg[0].current_ratio, 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, 6.0), sugg[0].suggested_ratio, 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, 20.0), sugg[0].drift_pct, 1e-9); +} + +test "ratioSuggestions: direct-indexing suggests at ratio 1.0; plain 1.0 does not" { + 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 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 = "1234", .comparisons = &cmps, .portfolio_total = 50000, .brokerage_total = 51000, .total_delta = 1000, .option_value_delta = 0, .has_discrepancies = true }, + }; + // Plain 1.0 lot, no direct-indexing -> gated out. + { + const sugg = try ratioSuggestions(allocator, &results, portfolio, prices, null); + defer allocator.free(sugg); + try std.testing.expectEqual(@as(usize, 0), sugg.len); + } + // Direct-indexing bypasses the 1.0 gate: 510/500 = 1.02, +2% drift. + { + 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 ratioSuggestions(allocator, &results, portfolio, prices, am); + defer allocator.free(sugg); + try std.testing.expectEqual(@as(usize, 1), sugg.len); + 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 "ratioSuggestions: zero drift produces no suggestion" { + const allocator = std.testing.allocator; + // Configured ratio already equals inst_nav/retail (30/5 = 6.0). + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "VTHRX", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 30, .account = "Sample IRA", .price_ratio = 6.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); + const cmps = [_]SymbolComparison{ + .{ .symbol = "VTHRX", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 30.0, .brokerage_price = 30.0, .portfolio_value = 3000, .brokerage_value = 3000, .shares_delta = 0, .value_delta = 0, .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 = 3000, .brokerage_total = 3000, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false }, + }; + const sugg = try ratioSuggestions(allocator, &results, portfolio, prices, null); + defer allocator.free(sugg); + try std.testing.expectEqual(@as(usize, 0), sugg.len); +} + +test "ratioSuggestions: cash/option/only rows and missing prices are skipped" { + 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 }; + // No price for VTHRX -> even the matching lot is skipped. + 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 }, + // matched non-cash row, but VTHRX has no cached retail price -> skipped + .{ .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 = 4350, .brokerage_total = 6950, .total_delta = 2600, .option_value_delta = 1500, .has_discrepancies = true }, + }; + const sugg = try ratioSuggestions(allocator, &results, portfolio, prices, null); + defer allocator.free(sugg); + try std.testing.expectEqual(@as(usize, 0), sugg.len); +} diff --git a/src/analytics/reconcile/schwab.zig b/src/analytics/reconcile/schwab.zig index 60e1746..60485fb 100644 --- a/src/analytics/reconcile/schwab.zig +++ b/src/analytics/reconcile/schwab.zig @@ -147,6 +147,74 @@ pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool { 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"); @@ -553,3 +621,90 @@ test "compareSchwabSummary: populates option flag and CD allowance from the port 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); + } +} diff --git a/src/brokerage.zig b/src/brokerage.zig new file mode 100644 index 0000000..ede8bb5 --- /dev/null +++ b/src/brokerage.zig @@ -0,0 +1,14 @@ +//! Brokerage export parsers, grouped for downstream consumers. +//! +//! Each broker module parses its export (positions CSV, and for Schwab +//! an account-summary paste) into the normalized `BrokeragePosition` +//! shape in `types.zig`. Pure functions: `(allocator, data) -> parsed`, +//! no IO. The reconciliation layer (`analytics/reconcile`) and finrev +//! consume these. + +pub const types = @import("brokerage/types.zig"); +pub const schwab = @import("brokerage/schwab.zig"); +pub const fidelity = @import("brokerage/fidelity.zig"); +pub const wells_fargo = @import("brokerage/wells_fargo.zig"); + +pub const BrokeragePosition = types.BrokeragePosition; diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 3701015..fb4dedf 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -199,7 +199,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer allocator.free(results); try schwab.displaySchwabResults(results, color, out); - try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); + try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results); defer allocator.free(present); @@ -231,7 +231,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { } try common.displayResults(results, color, out); - try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); + try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); const present = try common.presentNumbers(allocator, common.AccountComparison, results); defer allocator.free(present); @@ -263,7 +263,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { } try common.displayResults(results, color, out); - try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); + try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); const present = try common.presentNumbers(allocator, common.AccountComparison, results); defer allocator.free(present); diff --git a/src/commands/audit/common.zig b/src/commands/audit/common.zig index d3cf7bb..0e4a54d 100644 --- a/src/commands/audit/common.zig +++ b/src/commands/audit/common.zig @@ -49,6 +49,7 @@ pub const findAbsentAccounts = reconcile.findAbsentAccounts; /// applied against the existing lot share count, same formula as /// the institutional-class case. pub fn displayRatioSuggestions( + allocator: std.mem.Allocator, results: []const AccountComparison, portfolio: zfin.Portfolio, prices: std.StringHashMap(f64), @@ -56,75 +57,30 @@ pub fn displayRatioSuggestions( color: bool, out: *std.Io.Writer, ) !void { - var has_header = false; + const suggestions = try reconcile.ratioSuggestions(allocator, results, portfolio, prices, account_map); + defer allocator.free(suggestions); + if (suggestions.len == 0) return; - 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; + try out.print("\n", .{}); + try cli.printBold(out, color, " Ratio updates", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf)\n", .{}); - // 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; + for (suggestions) |s| { + 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}", .{s.current_ratio}) catch "?"; + const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{s.suggested_ratio}) catch "?"; + const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.2}%", .{s.drift_pct}) catch "?"; - // 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 - } - } + try out.print(" {s:<16} ", .{s.lot_symbol}); + try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{s.price_symbol}); + 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", .{}); + try out.print("\n", .{}); } // ── Display ───────────────────────────────────────────────── @@ -489,7 +445,7 @@ test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from co var buf: [2048]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); - try displayRatioSuggestions(&results, portfolio, prices, null, false, &w); + try displayRatioSuggestions(allocator, &results, portfolio, prices, null, false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null); @@ -526,7 +482,7 @@ test "displayRatioSuggestions: direct-indexing account suggests even at ratio 1. var buf: [2048]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); - try displayRatioSuggestions(&results, portfolio, prices, acct_map, false, &w); + try displayRatioSuggestions(allocator, &results, portfolio, prices, acct_map, false, &w); const out = w.buffered(); // suggested = 510/500 = 1.02, configured 1.0 -> drift suggestion emitted @@ -553,7 +509,7 @@ test "displayRatioSuggestions: cash/option/only rows produce no output" { var buf: [1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); - try displayRatioSuggestions(&results, portfolio, prices, null, false, &w); + try displayRatioSuggestions(allocator, &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); } diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index 9499daa..c4e454d 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -1233,7 +1233,7 @@ pub fn runHygieneCheck( 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); + try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); } else { var acct_count: usize = 0; for (results) |r| { @@ -1243,7 +1243,7 @@ pub fn runHygieneCheck( // 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 schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); } try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results); @@ -1261,11 +1261,11 @@ pub fn runHygieneCheck( 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); + try common.displayRatioSuggestions(allocator, 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.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); } try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results); @@ -1283,10 +1283,10 @@ pub fn runHygieneCheck( 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); + try common.displayRatioSuggestions(allocator, 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.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); } try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results); diff --git a/src/commands/audit/schwab.zig b/src/commands/audit/schwab.zig index c53504d..edd24ea 100644 --- a/src/commands/audit/schwab.zig +++ b/src/commands/audit/schwab.zig @@ -161,6 +161,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo /// Skips accounts with more than one stock lot (can't allocate the /// delta) or zero stock lots (nothing to adjust). pub fn displaySchwabSummaryRatioSuggestions( + allocator: std.mem.Allocator, results: []const SchwabAccountComparison, portfolio: zfin.Portfolio, prices: std.StringHashMap(f64), @@ -168,61 +169,30 @@ pub fn displaySchwabSummaryRatioSuggestions( color: bool, out: *std.Io.Writer, ) !void { - const am = account_map orelse return; - var has_header = false; + const suggestions = try reconcile.summaryRatioSuggestions(allocator, results, portfolio, prices, account_map); + defer allocator.free(suggestions); + if (suggestions.len == 0) return; - 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; - } + 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", .{}); + for (suggestions) |s| { 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 "?"; + const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{s.current_ratio}) catch "?"; + const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{s.suggested_ratio}) catch "?"; + const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.4}%", .{s.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(" {s:<16} ", .{s.lot_symbol}); + try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{s.price_symbol}); 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", .{}); + try out.print("\n", .{}); } // ── displaySchwabResults rendering ─────────────────────────── @@ -344,7 +314,7 @@ test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot dir var buf: [2048]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); - try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w); + try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, acct_map, false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null); @@ -363,7 +333,7 @@ test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" { }; var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); - try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, null, false, &w); + try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, null, false, &w); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } @@ -388,6 +358,6 @@ test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipp }; var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); - try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w); + try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, acct_map, false, &w); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } diff --git a/src/root.zig b/src/root.zig index 4ec5acb..f500ac2 100644 --- a/src/root.zig +++ b/src/root.zig @@ -84,6 +84,15 @@ pub const indicators = @import("analytics/indicators.zig"); /// Fundamental analysis: valuation, momentum, quality, and yield scoring. pub const analysis = @import("analytics/analysis.zig"); +/// Portfolio-vs-brokerage reconciliation (pure compute): compare +/// accounts, ratio-update suggestions, absent-account detection. The +/// audit command renders these; downstream tools (finrev) consume them. +pub const reconcile = @import("analytics/reconcile.zig"); + +/// Brokerage export parsers (Schwab/Fidelity/Wells Fargo positions + +/// summary) and the normalized `BrokeragePosition` shape. +pub const brokerage = @import("brokerage.zig"); + // ── Market calendar ────────────────────────────────────────── /// Trading-day calendar (NYSE holidays, weekends) and market-aware