separate ratio suggestion calculation from display, export types for others to use
This commit is contained in:
parent
940c745dd3
commit
a107008b35
9 changed files with 422 additions and 123 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
14
src/brokerage.zig
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue