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 hasAccountDiscrepancies = common.hasAccountDiscrepancies;
|
||||||
pub const presentNumbers = common.presentNumbers;
|
pub const presentNumbers = common.presentNumbers;
|
||||||
pub const findAbsentAccounts = common.findAbsentAccounts;
|
pub const findAbsentAccounts = common.findAbsentAccounts;
|
||||||
|
pub const RatioSuggestion = common.RatioSuggestion;
|
||||||
|
pub const ratioSuggestions = common.ratioSuggestions;
|
||||||
|
|
||||||
pub const SchwabAccountComparison = schwab.SchwabAccountComparison;
|
pub const SchwabAccountComparison = schwab.SchwabAccountComparison;
|
||||||
pub const compareSchwabSummary = schwab.compareSchwabSummary;
|
pub const compareSchwabSummary = schwab.compareSchwabSummary;
|
||||||
pub const reconcileCsv = schwab.reconcileCsv;
|
pub const reconcileCsv = schwab.reconcileCsv;
|
||||||
pub const reconcileSummary = schwab.reconcileSummary;
|
pub const reconcileSummary = schwab.reconcileSummary;
|
||||||
pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies;
|
pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies;
|
||||||
|
pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions;
|
||||||
|
|
||||||
pub const reconcileFidelity = fidelity.reconcile;
|
pub const reconcileFidelity = fidelity.reconcile;
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,90 @@ pub fn findAbsentAccounts(
|
||||||
|
|
||||||
return results.toOwnedSlice(allocator);
|
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 ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
test "consolidateBySymbol: distinct symbols pass through unchanged" {
|
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);
|
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;
|
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 ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const portfolio_mod = @import("../../models/portfolio.zig");
|
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.expect(results[0].has_options);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].cd_allowance, 0.01);
|
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);
|
defer allocator.free(results);
|
||||||
|
|
||||||
try schwab.displaySchwabResults(results, color, out);
|
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);
|
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
|
||||||
defer allocator.free(present);
|
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.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);
|
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
||||||
defer allocator.free(present);
|
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.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);
|
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
||||||
defer allocator.free(present);
|
defer allocator.free(present);
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ pub const findAbsentAccounts = reconcile.findAbsentAccounts;
|
||||||
/// applied against the existing lot share count, same formula as
|
/// applied against the existing lot share count, same formula as
|
||||||
/// the institutional-class case.
|
/// the institutional-class case.
|
||||||
pub fn displayRatioSuggestions(
|
pub fn displayRatioSuggestions(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
results: []const AccountComparison,
|
results: []const AccountComparison,
|
||||||
portfolio: zfin.Portfolio,
|
portfolio: zfin.Portfolio,
|
||||||
prices: std.StringHashMap(f64),
|
prices: std.StringHashMap(f64),
|
||||||
|
|
@ -56,75 +57,30 @@ pub fn displayRatioSuggestions(
|
||||||
color: bool,
|
color: bool,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
) !void {
|
) !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| {
|
try out.print("\n", .{});
|
||||||
for (acct.comparisons) |cmp| {
|
try cli.printBold(out, color, " Ratio updates", .{});
|
||||||
// Skip unmatched, cash, and option rows
|
try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf)\n", .{});
|
||||||
if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue;
|
|
||||||
if (cmp.is_cash or cmp.is_option) continue;
|
|
||||||
|
|
||||||
// Is this account flagged direct-indexing? Captured once
|
for (suggestions) |s| {
|
||||||
// per outer loop so the per-lot gate can skip the
|
var cur_buf: [24]u8 = undefined;
|
||||||
// ratio == 1.0 check for flagged accounts.
|
var sug_buf: [24]u8 = undefined;
|
||||||
const is_direct_indexing = if (account_map) |am|
|
var drift_buf: [16]u8 = undefined;
|
||||||
am.isDirectIndexing(acct.account_name)
|
const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{s.current_ratio}) catch "?";
|
||||||
else
|
const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{s.suggested_ratio}) catch "?";
|
||||||
false;
|
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
|
try out.print(" {s:<16} ", .{s.lot_symbol});
|
||||||
// (or any ratio, for direct-indexing accounts).
|
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{s.price_symbol});
|
||||||
for (portfolio.lots) |lot| {
|
try out.print(" ratio {s} -> ", .{cur_str});
|
||||||
if (lot.price_ratio == 1.0 and !is_direct_indexing) continue;
|
try cli.printBold(out, color, "{s}", .{sug_str});
|
||||||
if (lot.security_type != .stock) continue;
|
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str});
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (has_header) try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Display ─────────────────────────────────────────────────
|
// ── Display ─────────────────────────────────────────────────
|
||||||
|
|
@ -489,7 +445,7 @@ test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from co
|
||||||
|
|
||||||
var buf: [2048]u8 = undefined;
|
var buf: [2048]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
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();
|
const out = w.buffered();
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
|
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 buf: [2048]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
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();
|
const out = w.buffered();
|
||||||
|
|
||||||
// suggested = 510/500 = 1.02, configured 1.0 -> drift suggestion emitted
|
// 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 buf: [1024]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
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.
|
// No qualifying (matched, non-cash, non-option) rows -> header never prints.
|
||||||
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1233,7 +1233,7 @@ pub fn runHygieneCheck(
|
||||||
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
|
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try schwab.displaySchwabResults(results, color, out);
|
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 {
|
} else {
|
||||||
var acct_count: usize = 0;
|
var acct_count: usize = 0;
|
||||||
for (results) |r| {
|
for (results) |r| {
|
||||||
|
|
@ -1243,7 +1243,7 @@ pub fn runHygieneCheck(
|
||||||
// Always show ratio suggestions even in compact
|
// Always show ratio suggestions even in compact
|
||||||
// mode - direct-indexing drift may cause a
|
// mode - direct-indexing drift may cause a
|
||||||
// non-zero delta that still deserves a nudge.
|
// 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);
|
try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
|
||||||
|
|
@ -1261,11 +1261,11 @@ pub fn runHygieneCheck(
|
||||||
if (verbose or common.hasAccountDiscrepancies(results)) {
|
if (verbose or common.hasAccountDiscrepancies(results)) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try common.displayResults(results, color, out);
|
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 {
|
} else {
|
||||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len});
|
try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len});
|
||||||
// Always show ratio suggestions even in compact mode
|
// 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);
|
try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
|
||||||
|
|
@ -1283,10 +1283,10 @@ pub fn runHygieneCheck(
|
||||||
if (verbose or common.hasAccountDiscrepancies(results)) {
|
if (verbose or common.hasAccountDiscrepancies(results)) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try common.displayResults(results, color, out);
|
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 {
|
} else {
|
||||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
|
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);
|
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
|
/// Skips accounts with more than one stock lot (can't allocate the
|
||||||
/// delta) or zero stock lots (nothing to adjust).
|
/// delta) or zero stock lots (nothing to adjust).
|
||||||
pub fn displaySchwabSummaryRatioSuggestions(
|
pub fn displaySchwabSummaryRatioSuggestions(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
results: []const SchwabAccountComparison,
|
results: []const SchwabAccountComparison,
|
||||||
portfolio: zfin.Portfolio,
|
portfolio: zfin.Portfolio,
|
||||||
prices: std.StringHashMap(f64),
|
prices: std.StringHashMap(f64),
|
||||||
|
|
@ -168,61 +169,30 @@ pub fn displaySchwabSummaryRatioSuggestions(
|
||||||
color: bool,
|
color: bool,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
) !void {
|
) !void {
|
||||||
const am = account_map orelse return;
|
const suggestions = try reconcile.summaryRatioSuggestions(allocator, results, portfolio, prices, account_map);
|
||||||
var has_header = false;
|
defer allocator.free(suggestions);
|
||||||
|
if (suggestions.len == 0) return;
|
||||||
|
|
||||||
for (results) |r| {
|
try out.print("\n", .{});
|
||||||
if (r.account_name.len == 0) continue;
|
try cli.printBold(out, color, " Ratio updates", .{});
|
||||||
if (!am.isDirectIndexing(r.account_name)) continue;
|
try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf; direct-indexing accounts)\n", .{});
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
for (suggestions) |s| {
|
||||||
var cur_buf: [24]u8 = undefined;
|
var cur_buf: [24]u8 = undefined;
|
||||||
var sug_buf: [24]u8 = undefined;
|
var sug_buf: [24]u8 = undefined;
|
||||||
var drift_buf: [16]u8 = undefined;
|
var drift_buf: [16]u8 = undefined;
|
||||||
const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{lot.price_ratio}) catch "?";
|
const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{s.current_ratio}) catch "?";
|
||||||
const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_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}%", .{drift_pct}) catch "?";
|
const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.4}%", .{s.drift_pct}) catch "?";
|
||||||
|
|
||||||
try out.print(" {s:<16} ", .{lot.symbol});
|
try out.print(" {s:<16} ", .{s.lot_symbol});
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym});
|
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{s.price_symbol});
|
||||||
try out.print(" ratio {s} -> ", .{cur_str});
|
try out.print(" ratio {s} -> ", .{cur_str});
|
||||||
try cli.printBold(out, color, "{s}", .{sug_str});
|
try cli.printBold(out, color, "{s}", .{sug_str});
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_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 ───────────────────────────
|
// ── displaySchwabResults rendering ───────────────────────────
|
||||||
|
|
||||||
|
|
@ -344,7 +314,7 @@ test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot dir
|
||||||
|
|
||||||
var buf: [2048]u8 = undefined;
|
var buf: [2048]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
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();
|
const out = w.buffered();
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
|
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 buf: [512]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
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);
|
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 buf: [512]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
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);
|
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.
|
/// Fundamental analysis: valuation, momentum, quality, and yield scoring.
|
||||||
pub const analysis = @import("analytics/analysis.zig");
|
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 ──────────────────────────────────────────
|
// ── Market calendar ──────────────────────────────────────────
|
||||||
|
|
||||||
/// Trading-day calendar (NYSE holidays, weekends) and market-aware
|
/// Trading-day calendar (NYSE holidays, weekends) and market-aware
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue