separate ratio suggestion calculation from display, export types for others to use
All checks were successful
Generic zig build / build (push) Successful in 4m55s
Generic zig build / publish-macos (push) Successful in 11s
Generic zig build / deploy (push) Successful in 19s

This commit is contained in:
Emil Lerch 2026-08-02 09:25:55 -07:00
parent 940c745dd3
commit a107008b35
Signed by: lobo
GPG key ID: A7B62D657EF764F8
9 changed files with 422 additions and 123 deletions

View file

@ -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;

View file

@ -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);
}

View file

@ -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);
}
}

14
src/brokerage.zig Normal file
View file

@ -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;

View file

@ -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);

View file

@ -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);
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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