Compare commits
No commits in common. "a966eed026ae4d729ec36191e5fa5ce6af2041c9" and "d5459ff39e10b5f625112d615d457a74e8f8d16e" have entirely different histories.
a966eed026
...
d5459ff39e
9 changed files with 61 additions and 814 deletions
|
|
@ -338,21 +338,7 @@ Portfolio Audit (brokerage is source of truth)
|
||||||
- **Brokerage-only** rows are holdings missing from your portfolio.
|
- **Brokerage-only** rows are holdings missing from your portfolio.
|
||||||
- A share or value **delta** flags a count or price mismatch.
|
- A share or value **delta** flags a count or price mismatch.
|
||||||
|
|
||||||
When the flagless audit finds an export that covers several accounts, it
|
`--verbose` prints the full comparison even when everything reconciles.
|
||||||
only shows a table for the accounts with something to look at. That
|
|
||||||
includes muted rows: an account whose only difference is a stale price
|
|
||||||
still gets its table. The other accounts are named on one line, so you
|
|
||||||
can see they were checked, and the totals still include them:
|
|
||||||
|
|
||||||
```
|
|
||||||
2 more accounts, no discrepancies: Sample IRA, Sample Trust
|
|
||||||
|
|
||||||
Total: portfolio $412,350.00 brokerage $412,670.40 delta +$320.40
|
|
||||||
```
|
|
||||||
|
|
||||||
`--verbose` shows a table for every account, and prints the full
|
|
||||||
comparison even when everything reconciles. The explicit flags
|
|
||||||
(`--schwab FILE` etc.) always show every account in the file.
|
|
||||||
|
|
||||||
### Discrepancies, and which ones are muted
|
### Discrepancies, and which ones are muted
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ discrepancies.
|
||||||
|
|
||||||
| Flag | Effect |
|
| Flag | Effect |
|
||||||
|-----------------------|---------------------------------------------------------------------------------------------|
|
|-----------------------|---------------------------------------------------------------------------------------------|
|
||||||
| `--verbose` | Show a table for every account, not just mismatched ones, even when clean. |
|
| `--verbose` | Show full reconciliation output even when clean. |
|
||||||
| `--stale-days <N>` | Manual-price staleness threshold (default 3). |
|
| `--stale-days <N>` | Manual-price staleness threshold (default 3). |
|
||||||
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
|
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
|
||||||
| `--schwab <CSV>` | Schwab positions CSV: one account, or "All Brokerage Accounts". |
|
| `--schwab <CSV>` | Schwab positions CSV: one account, or "All Brokerage Accounts". |
|
||||||
|
|
@ -34,12 +34,6 @@ unmatched accounts on one "not in accounts.srf, not reconciled" line,
|
||||||
and one with no matched accounts at all is skipped as another
|
and one with no matched accounts at all is skipped as another
|
||||||
portfolio's. See [how accounts are matched](../../guides/audit-against-brokerage.md#how-accounts-are-matched).
|
portfolio's. See [how accounts are matched](../../guides/audit-against-brokerage.md#how-accounts-are-matched).
|
||||||
|
|
||||||
When the flagless audit reconciles a discovered export, only accounts
|
|
||||||
with a discrepancy get a full table. The rest of that export's accounts
|
|
||||||
are named on one "N more accounts, no discrepancies" line, and the
|
|
||||||
totals still cover all of them. `--verbose` and the explicit brokerage
|
|
||||||
flags show a table for every account.
|
|
||||||
|
|
||||||
The hygiene check also flags newly-appeared lots worth at least
|
The hygiene check also flags newly-appeared lots worth at least
|
||||||
$10,000 in a **Large new lots - confirm source** section, so you can
|
$10,000 in a **Large new lots - confirm source** section, so you can
|
||||||
confirm whether each is a real contribution or an unrecorded transfer.
|
confirm whether each is a real contribution or an unrecorded transfer.
|
||||||
|
|
|
||||||
|
|
@ -488,15 +488,6 @@ pub fn compareAccounts(
|
||||||
|
|
||||||
const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null;
|
const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null;
|
||||||
|
|
||||||
// Nothing in the portfolio for this row. A broker cash line
|
|
||||||
// that is itself (near) zero is agreement, not a holding the
|
|
||||||
// portfolio is missing: Schwab always emits a "Cash & Cash
|
|
||||||
// Investments" row, even at $0.00, and cash rows carry no
|
|
||||||
// quantity, so without this a fully-invested account with no
|
|
||||||
// cash lot showed a "Brokerage only" mismatch that
|
|
||||||
// `has_discrepancies` (value delta $0) disagreed with.
|
|
||||||
const zero_cash_row = bp.is_cash and @abs(bp_value) < cash_tolerance;
|
|
||||||
|
|
||||||
try comparisons.append(allocator, .{
|
try comparisons.append(allocator, .{
|
||||||
.symbol = bp.symbol,
|
.symbol = bp.symbol,
|
||||||
.portfolio_shares = pf_shares,
|
.portfolio_shares = pf_shares,
|
||||||
|
|
@ -511,7 +502,7 @@ pub fn compareAccounts(
|
||||||
.is_option = is_option,
|
.is_option = is_option,
|
||||||
.is_cd = is_cd,
|
.is_cd = is_cd,
|
||||||
.cd_allowance = cd_allowance,
|
.cd_allowance = cd_allowance,
|
||||||
.only_in_brokerage = pf_shares == 0 and pf_value == 0 and !zero_cash_row,
|
.only_in_brokerage = pf_shares == 0 and pf_value == 0,
|
||||||
.only_in_portfolio = false,
|
.only_in_portfolio = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1375,58 +1366,6 @@ test "compareAccounts: no cash row and no portfolio cash stays silent" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test "compareAccounts: a $0.00 broker cash row with no cash lot is agreement, not brokerage-only" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
|
|
||||||
// Schwab emits its "Cash & Cash Investments" line even for a fully
|
|
||||||
// invested account. With no cash lot in the portfolio, both sides
|
|
||||||
// say zero - which used to render as a counted "Brokerage only"
|
|
||||||
// mismatch while `has_discrepancies` said the account was clean.
|
|
||||||
var lots = [_]portfolio_mod.Lot{
|
|
||||||
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample IRA" },
|
|
||||||
};
|
|
||||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
|
||||||
|
|
||||||
var entries = [_]analysis.AccountTaxEntry{
|
|
||||||
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" },
|
|
||||||
};
|
|
||||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
|
||||||
|
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
|
||||||
defer prices.deinit();
|
|
||||||
try prices.put("VTI", 300);
|
|
||||||
|
|
||||||
const Case = struct { cash: f64, flagged: bool };
|
|
||||||
// $0.00 and sub-penny agree; a real balance with no lot is still
|
|
||||||
// brokerage-only cash the portfolio is missing.
|
|
||||||
const cases = [_]Case{
|
|
||||||
.{ .cash = 0, .flagged = false },
|
|
||||||
.{ .cash = 0.004, .flagged = false },
|
|
||||||
.{ .cash = 50, .flagged = true },
|
|
||||||
};
|
|
||||||
for (cases) |c| {
|
|
||||||
var brokerage = [_]BrokeragePosition{
|
|
||||||
.{ .account_number = "1234", .account_name = "Sample_IRA", .symbol = "VTI", .description = "", .quantity = 100, .current_value = 30000, .cost_basis = 20000, .is_cash = false },
|
|
||||||
.{ .account_number = "1234", .account_name = "Sample_IRA", .symbol = "Cash & Cash Investments", .description = "", .quantity = null, .current_value = c.cash, .cost_basis = null, .is_cash = true },
|
|
||||||
};
|
|
||||||
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 8, 1));
|
|
||||||
defer {
|
|
||||||
for (results) |r| allocator.free(r.comparisons);
|
|
||||||
allocator.free(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
|
||||||
try std.testing.expectEqual(c.flagged, results[0].has_discrepancies);
|
|
||||||
var found = false;
|
|
||||||
for (results[0].comparisons) |cmp| {
|
|
||||||
if (!cmp.is_cash) continue;
|
|
||||||
found = true;
|
|
||||||
try std.testing.expectEqual(c.flagged, cmp.only_in_brokerage);
|
|
||||||
}
|
|
||||||
try std.testing.expect(found);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test "compareAccounts: portfolio-only stock position is flagged only_in_portfolio" {
|
test "compareAccounts: portfolio-only stock position is flagged only_in_portfolio" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show one explicitly-requested per-position export: the comparison
|
/// Show one explicitly-requested per-position export: the comparison
|
||||||
/// (a table for every account, clean or not - you asked about this file)
|
|
||||||
/// and ratio suggestions for its mapped accounts, then its unmapped ones
|
/// and ratio suggestions for its mapped accounts, then its unmapped ones
|
||||||
/// (see `common.splitUnmapped`). The unmapped list is printed even for an
|
/// (see `common.splitUnmapped`). The unmapped list is printed even for an
|
||||||
/// export skipped as another portfolio's - you pointed audit at this
|
/// export skipped as another portfolio's - you pointed audit at this
|
||||||
|
|
@ -366,7 +365,7 @@ fn showAccountExport(
|
||||||
) !void {
|
) !void {
|
||||||
const view = common.splitUnmapped(common.AccountComparison, results, scope);
|
const view = common.splitUnmapped(common.AccountComparison, results, scope);
|
||||||
if (!view.skipped()) {
|
if (!view.skipped()) {
|
||||||
try common.displayResults(view.shown, .all, color, out);
|
try common.displayResults(view.shown, color, out);
|
||||||
try common.displayRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, color, out);
|
try common.displayRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, color, out);
|
||||||
}
|
}
|
||||||
try common.displayUnmapped(common.AccountComparison, label, view, true, color, out);
|
try common.displayUnmapped(common.AccountComparison, label, view, true, color, out);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ pub const accountValueExpectation = reconcile.accountValueExpectation;
|
||||||
pub const SymbolComparison = reconcile.SymbolComparison;
|
pub const SymbolComparison = reconcile.SymbolComparison;
|
||||||
pub const AccountComparison = reconcile.AccountComparison;
|
pub const AccountComparison = reconcile.AccountComparison;
|
||||||
pub const compareAccounts = reconcile.compareAccounts;
|
pub const compareAccounts = reconcile.compareAccounts;
|
||||||
|
pub const hasAccountDiscrepancies = reconcile.hasAccountDiscrepancies;
|
||||||
pub const ExportScope = reconcile.ExportScope;
|
pub const ExportScope = reconcile.ExportScope;
|
||||||
pub const ExportView = reconcile.ExportView;
|
pub const ExportView = reconcile.ExportView;
|
||||||
pub const splitUnmapped = reconcile.splitUnmapped;
|
pub const splitUnmapped = reconcile.splitUnmapped;
|
||||||
|
|
@ -128,84 +129,7 @@ pub fn displayRatioSuggestions(
|
||||||
|
|
||||||
// ── Display ─────────────────────────────────────────────────
|
// ── Display ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// How one comparison row reads in the audit table. Shared by the
|
pub fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void {
|
||||||
/// renderer and by `accountNeedsDetail`, so the rows counted "to
|
|
||||||
/// investigate" and the accounts given a table can't drift apart.
|
|
||||||
const RowClass = struct {
|
|
||||||
shares_ok: bool,
|
|
||||||
is_cash_mismatch: bool,
|
|
||||||
/// A warning-coloured, counted discrepancy - as opposed to a muted
|
|
||||||
/// stale value or in-band CD mark, which is shown but not counted.
|
|
||||||
real_mismatch: bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn classifyRow(cmp: SymbolComparison) RowClass {
|
|
||||||
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage;
|
|
||||||
const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= cash_tolerance else false);
|
|
||||||
// A CD's value delta is an expected dealer mark up to its
|
|
||||||
// band; only a delta beyond what its terms can explain is a
|
|
||||||
// real discrepancy. Stale stock prices (shares match, no CD)
|
|
||||||
// stay muted, as before.
|
|
||||||
const cd_band = @max(value_tolerance, cmp.cd_allowance);
|
|
||||||
const cd_value_exceeded = cmp.is_cd and (if (cmp.value_delta) |d| @abs(d) > cd_band else false);
|
|
||||||
return .{
|
|
||||||
.shares_ok = shares_ok,
|
|
||||||
.is_cash_mismatch = is_cash_mismatch,
|
|
||||||
.real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch or cd_value_exceeded,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when the row is one of the "N mismatches to investigate".
|
|
||||||
pub fn isRealMismatch(cmp: SymbolComparison) bool {
|
|
||||||
return classifyRow(cmp).real_mismatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Does this account warrant its own table in the routine audit?
|
|
||||||
///
|
|
||||||
/// True when the reconciler flagged anything (`has_discrepancies`: a row
|
|
||||||
/// off beyond its tolerance, which includes the muted stale-price and
|
|
||||||
/// in-band CD rows - muted is shown, never hidden), or when any row is a
|
|
||||||
/// counted mismatch. The second half is what guarantees the "to
|
|
||||||
/// investigate" count never includes a row in an account that isn't
|
|
||||||
/// shown: the two predicates disagree for a brokerage-only row with no
|
|
||||||
/// quantity and a value under the $1 securities tolerance.
|
|
||||||
///
|
|
||||||
/// An account's summed sub-tolerance rounding (many rows each a few
|
|
||||||
/// cents off) does not qualify on its own, matching what the per-file
|
|
||||||
/// gate this replaced did.
|
|
||||||
pub fn accountNeedsDetail(acct: AccountComparison) bool {
|
|
||||||
if (acct.has_discrepancies) return true;
|
|
||||||
for (acct.comparisons) |cmp| {
|
|
||||||
if (isRealMismatch(cmp)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when any account in `results` needs a table (see
|
|
||||||
/// `accountNeedsDetail`).
|
|
||||||
pub fn anyAccountNeedsDetail(results: []const AccountComparison) bool {
|
|
||||||
for (results) |acct| {
|
|
||||||
if (accountNeedsDetail(acct)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How much of an export `displayResults` renders as tables.
|
|
||||||
pub const Detail = enum {
|
|
||||||
/// Every account gets a table: `--verbose`, and an export audit was
|
|
||||||
/// pointed at explicitly (`--schwab FILE` etc.).
|
|
||||||
all,
|
|
||||||
/// Only accounts that `accountNeedsDetail`; the others are named on
|
|
||||||
/// one line above the totals. The flagless audit's routine view, so
|
|
||||||
/// a single bad account in an all-accounts export doesn't print a
|
|
||||||
/// screenful of matching ones.
|
|
||||||
discrepancies_only,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Render one export's comparison. The totals and the "to investigate"
|
|
||||||
/// count always cover every account in `results`, whatever `detail`
|
|
||||||
/// leaves out - an account left out has no counted rows by construction.
|
|
||||||
pub fn displayResults(results: []const AccountComparison, detail: Detail, color: bool, out: *std.Io.Writer) !void {
|
|
||||||
try cli.printBold(out, color, "\nPortfolio Audit", .{});
|
try cli.printBold(out, color, "\nPortfolio Audit", .{});
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
|
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
|
||||||
try out.print("========================================\n\n", .{});
|
try out.print("========================================\n\n", .{});
|
||||||
|
|
@ -214,20 +138,8 @@ pub fn displayResults(results: []const AccountComparison, detail: Detail, color:
|
||||||
var total_brokerage: f64 = 0;
|
var total_brokerage: f64 = 0;
|
||||||
var total_option_delta: f64 = 0;
|
var total_option_delta: f64 = 0;
|
||||||
var discrepancy_count: usize = 0;
|
var discrepancy_count: usize = 0;
|
||||||
var shown: usize = 0;
|
|
||||||
var hidden: usize = 0;
|
|
||||||
|
|
||||||
for (results) |acct| {
|
for (results) |acct| {
|
||||||
total_portfolio += acct.portfolio_total;
|
|
||||||
total_brokerage += acct.brokerage_total;
|
|
||||||
total_option_delta += acct.option_value_delta;
|
|
||||||
|
|
||||||
if (detail == .discrepancies_only and !accountNeedsDetail(acct)) {
|
|
||||||
hidden += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
shown += 1;
|
|
||||||
|
|
||||||
// Account header
|
// Account header
|
||||||
if (acct.account_name.len > 0) {
|
if (acct.account_name.len > 0) {
|
||||||
try cli.printBold(out, color, " {s}", .{acct.account_name});
|
try cli.printBold(out, color, " {s}", .{acct.account_name});
|
||||||
|
|
@ -249,10 +161,15 @@ pub fn displayResults(results: []const AccountComparison, detail: Detail, color:
|
||||||
var br_price_buf: [16]u8 = undefined;
|
var br_price_buf: [16]u8 = undefined;
|
||||||
|
|
||||||
// Classify this row
|
// Classify this row
|
||||||
const class = classifyRow(cmp);
|
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage;
|
||||||
const shares_ok = class.shares_ok;
|
const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= cash_tolerance else false);
|
||||||
const is_cash_mismatch = class.is_cash_mismatch;
|
// A CD's value delta is an expected dealer mark up to its
|
||||||
const is_real_mismatch = class.real_mismatch;
|
// band; only a delta beyond what its terms can explain is a
|
||||||
|
// real discrepancy. Stale stock prices (shares match, no CD)
|
||||||
|
// stay muted, as before.
|
||||||
|
const cd_band = @max(value_tolerance, cmp.cd_allowance);
|
||||||
|
const cd_value_exceeded = cmp.is_cd and (if (cmp.value_delta) |d| @abs(d) > cd_band else false);
|
||||||
|
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch or cd_value_exceeded;
|
||||||
|
|
||||||
if (is_real_mismatch) discrepancy_count += 1;
|
if (is_real_mismatch) discrepancy_count += 1;
|
||||||
|
|
||||||
|
|
@ -379,24 +296,10 @@ pub fn displayResults(results: []const AccountComparison, detail: Detail, color:
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) });
|
try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) });
|
||||||
}
|
}
|
||||||
try out.print("\n\n", .{});
|
try out.print("\n\n", .{});
|
||||||
}
|
|
||||||
|
|
||||||
// Accounts left out of a `.discrepancies_only` view, named so you can
|
total_portfolio += acct.portfolio_total;
|
||||||
// see they were covered (an account missing from the export entirely
|
total_brokerage += acct.brokerage_total;
|
||||||
// is the "not found in any export" advisory's job, not this line's).
|
total_option_delta += acct.option_value_delta;
|
||||||
if (hidden > 0) {
|
|
||||||
const more: []const u8 = if (shown > 0) " more" else "";
|
|
||||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " {d}{s} {s}, no discrepancies: ", .{ hidden, more, accountsNoun(hidden) });
|
|
||||||
var first = true;
|
|
||||||
for (results) |acct| {
|
|
||||||
if (accountNeedsDetail(acct)) continue;
|
|
||||||
if (!first) try cli.printFg(out, color, cli.CLR_POSITIVE, ", ", .{});
|
|
||||||
first = false;
|
|
||||||
// Always non-empty: an unmapped account is a discrepancy, so
|
|
||||||
// it never lands here.
|
|
||||||
try cli.printFg(out, color, cli.CLR_POSITIVE, "{s}", .{acct.account_name});
|
|
||||||
}
|
|
||||||
try out.print("\n\n", .{});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grand totals
|
// Grand totals
|
||||||
|
|
@ -482,7 +385,7 @@ test "displayResults: renders every row classification and the totals block" {
|
||||||
|
|
||||||
var buf: [8192]u8 = undefined;
|
var buf: [8192]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
try displayResults(&results, .all, false, &w);
|
try displayResults(&results, false, &w);
|
||||||
const out = w.buffered();
|
const out = w.buffered();
|
||||||
|
|
||||||
// Header + account identity
|
// Header + account identity
|
||||||
|
|
@ -519,7 +422,7 @@ test "displayResults: color=true emits ANSI and singular mismatch label" {
|
||||||
|
|
||||||
var buf: [4096]u8 = undefined;
|
var buf: [4096]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
try displayResults(&results, .all, true, &w);
|
try displayResults(&results, true, &w);
|
||||||
const out = w.buffered();
|
const out = w.buffered();
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null);
|
||||||
|
|
@ -540,7 +443,7 @@ test "displayResults: CD value delta within band is muted+uncounted, beyond band
|
||||||
};
|
};
|
||||||
var buf: [4096]u8 = undefined;
|
var buf: [4096]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
try displayResults(&results, .all, false, &w);
|
try displayResults(&results, false, &w);
|
||||||
const out = w.buffered();
|
const out = w.buffered();
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); // shown, never hidden
|
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); // shown, never hidden
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null); // not counted
|
try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null); // not counted
|
||||||
|
|
@ -555,132 +458,13 @@ test "displayResults: CD value delta within band is muted+uncounted, beyond band
|
||||||
};
|
};
|
||||||
var buf: [4096]u8 = undefined;
|
var buf: [4096]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
try displayResults(&results, .all, false, &w);
|
try displayResults(&results, false, &w);
|
||||||
const out = w.buffered();
|
const out = w.buffered();
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null); // counted
|
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null); // counted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── displayResults: per-account detail ───────────────────────
|
|
||||||
|
|
||||||
/// A matched, clean row: shares and value agree.
|
|
||||||
fn cleanRow(symbol: []const u8, value: f64) SymbolComparison {
|
|
||||||
return .{ .symbol = symbol, .portfolio_shares = 10, .brokerage_shares = 10, .portfolio_price = value / 10, .brokerage_price = value / 10, .portfolio_value = value, .brokerage_value = value, .shares_delta = 0, .value_delta = 0, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false };
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cleanAccount(name: []const u8, number: []const u8, cmps: []const SymbolComparison, total: f64) AccountComparison {
|
|
||||||
return .{ .account_name = name, .brokerage_name = "Sample_Account", .account_number = number, .comparisons = cmps, .portfolio_total = total, .brokerage_total = total, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The line of `out` starting at the first `prefix`, or "" if none.
|
|
||||||
fn lineStarting(out: []const u8, prefix: []const u8) []const u8 {
|
|
||||||
const rest = out[std.mem.indexOf(u8, out, prefix) orelse out.len ..];
|
|
||||||
return rest[0 .. std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len];
|
|
||||||
}
|
|
||||||
|
|
||||||
test "displayResults: discrepancies_only tables the accounts that need it and names the rest" {
|
|
||||||
// One share mismatch, one muted stale value (flagged by the
|
|
||||||
// reconciler, so it keeps its table: muted is shown, never hidden),
|
|
||||||
// and two clean accounts.
|
|
||||||
const dirty_cmps = [_]SymbolComparison{
|
|
||||||
.{ .symbol = "MSFT", .portfolio_shares = 5, .brokerage_shares = 7, .portfolio_price = 300, .brokerage_price = 300, .portfolio_value = 1500, .brokerage_value = 2100, .shares_delta = 2, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
||||||
};
|
|
||||||
const stale_cmps = [_]SymbolComparison{
|
|
||||||
.{ .symbol = "QTUM", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 116.0, .brokerage_price = 116.5, .portfolio_value = 11600, .brokerage_value = 11650, .shares_delta = 0, .value_delta = 50, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
||||||
};
|
|
||||||
const roth_cmps = [_]SymbolComparison{cleanRow("VTI", 3000)};
|
|
||||||
const trust_cmps = [_]SymbolComparison{cleanRow("SCHD", 4000)};
|
|
||||||
const results = [_]AccountComparison{
|
|
||||||
.{ .account_name = "Sample IRA", .brokerage_name = "Sample_IRA", .account_number = "1234", .comparisons = &dirty_cmps, .portfolio_total = 1500, .brokerage_total = 2100, .total_delta = 600, .option_value_delta = 0, .has_discrepancies = true },
|
|
||||||
cleanAccount("Sample Roth", "5678", &roth_cmps, 3000),
|
|
||||||
.{ .account_name = "Sample HSA", .brokerage_name = "Sample_HSA", .account_number = "9012", .comparisons = &stale_cmps, .portfolio_total = 11600, .brokerage_total = 11650, .total_delta = 50, .option_value_delta = 0, .has_discrepancies = true },
|
|
||||||
cleanAccount("Sample Trust", "3456", &trust_cmps, 4000),
|
|
||||||
};
|
|
||||||
|
|
||||||
var only_buf: [8192]u8 = undefined;
|
|
||||||
var only_w: std.Io.Writer = .fixed(&only_buf);
|
|
||||||
try displayResults(&results, .discrepancies_only, false, &only_w);
|
|
||||||
const only = only_w.buffered();
|
|
||||||
|
|
||||||
var all_buf: [8192]u8 = undefined;
|
|
||||||
var all_w: std.Io.Writer = .fixed(&all_buf);
|
|
||||||
try displayResults(&results, .all, false, &all_w);
|
|
||||||
const all = all_w.buffered();
|
|
||||||
|
|
||||||
// Tables: the share mismatch and the muted stale value.
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, only, " Sample IRA (Sample_IRA, #1234)\n") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, only, " Sample HSA (Sample_HSA, #9012)\n") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, only, "Value +$50.00") != null);
|
|
||||||
// The clean two: no table, named once on the summary line.
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, only, "#5678") == null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, only, "#3456") == null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, only, "VTI") == null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, only, "\n 2 more accounts, no discrepancies: Sample Roth, Sample Trust\n\n Total:") != null);
|
|
||||||
|
|
||||||
// `.all` tables everyone and has no summary line.
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, all, " Sample Roth (Sample_Account, #5678)\n") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, all, " Sample Trust (Sample_Account, #3456)\n") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, all, "no discrepancies") == null);
|
|
||||||
|
|
||||||
// Totals and the "to investigate" count cover every account, so
|
|
||||||
// hiding the clean ones changes neither.
|
|
||||||
const only_total = lineStarting(only, " Total:");
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, only_total, "portfolio $20,100.00 brokerage $20,750.00") != null);
|
|
||||||
try std.testing.expectEqualStrings(lineStarting(all, " Total:"), only_total);
|
|
||||||
try std.testing.expectEqualStrings(" 1 mismatch to investigate", lineStarting(only, " 1 mismatch"));
|
|
||||||
try std.testing.expectEqualStrings(lineStarting(all, " 1 mismatch"), lineStarting(only, " 1 mismatch"));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "displayResults: a counted row keeps its table even when the reconciler saw nothing" {
|
|
||||||
// A brokerage-only row with no quantity and a value under the $1
|
|
||||||
// securities tolerance: `has_discrepancies` stays false, but the
|
|
||||||
// table counts it. Hiding it would leave "1 mismatch to investigate"
|
|
||||||
// pointing at an account that isn't shown.
|
|
||||||
const odd_cmps = [_]SymbolComparison{
|
|
||||||
.{ .symbol = "SMPLX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 0, .brokerage_value = 0.5, .shares_delta = null, .value_delta = 0.5, .is_cash = false, .is_option = false, .only_in_brokerage = true, .only_in_portfolio = false },
|
|
||||||
};
|
|
||||||
const roth_cmps = [_]SymbolComparison{cleanRow("VTI", 3000)};
|
|
||||||
const results = [_]AccountComparison{
|
|
||||||
cleanAccount("Sample IRA", "1234", &odd_cmps, 0),
|
|
||||||
cleanAccount("Sample Roth", "5678", &roth_cmps, 3000),
|
|
||||||
};
|
|
||||||
try std.testing.expect(accountNeedsDetail(results[0]));
|
|
||||||
try std.testing.expect(!accountNeedsDetail(results[1]));
|
|
||||||
try std.testing.expect(anyAccountNeedsDetail(&results));
|
|
||||||
|
|
||||||
var buf: [4096]u8 = undefined;
|
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
|
||||||
try displayResults(&results, .discrepancies_only, false, &w);
|
|
||||||
const out = w.buffered();
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Brokerage only") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null);
|
|
||||||
// Singular.
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "\n 1 more account, no discrepancies: Sample Roth\n") != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "displayResults: discrepancies_only with nothing to table drops the \"more\"" {
|
|
||||||
// Callers gate on `anyAccountNeedsDetail` so this shouldn't happen,
|
|
||||||
// but the summary line must still read correctly if it does.
|
|
||||||
const ira_cmps = [_]SymbolComparison{cleanRow("VTI", 3000)};
|
|
||||||
const roth_cmps = [_]SymbolComparison{cleanRow("SCHD", 4000)};
|
|
||||||
const results = [_]AccountComparison{
|
|
||||||
cleanAccount("Sample IRA", "1234", &ira_cmps, 3000),
|
|
||||||
cleanAccount("Sample Roth", "5678", &roth_cmps, 4000),
|
|
||||||
};
|
|
||||||
try std.testing.expect(!anyAccountNeedsDetail(&results));
|
|
||||||
|
|
||||||
var buf: [4096]u8 = undefined;
|
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
|
||||||
try displayResults(&results, .discrepancies_only, true, &w);
|
|
||||||
const out = w.buffered();
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "2 accounts, no discrepancies: ") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "more") == null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Roth") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── displayRatioSuggestions ──────────────────────────────────
|
// ── displayRatioSuggestions ──────────────────────────────────
|
||||||
|
|
||||||
test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" {
|
test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" {
|
||||||
|
|
|
||||||
|
|
@ -738,8 +738,7 @@ fn accumulatePresent(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Report one discovered per-position export (Fidelity, a Schwab CSV,
|
/// Report one discovered per-position export (Fidelity, a Schwab CSV,
|
||||||
/// Wells Fargo): a table for each account with something to look at
|
/// Wells Fargo): the full comparison when `verbose` or anything is off,
|
||||||
/// (every account when `verbose`) and the clean rest named on one line,
|
|
||||||
/// else a one-line all-clear; then the export's unmapped accounts (see
|
/// else a one-line all-clear; then the export's unmapped accounts (see
|
||||||
/// `common.splitUnmapped`). Returns false when the export was skipped as
|
/// `common.splitUnmapped`). Returns false when the export was skipped as
|
||||||
/// another portfolio's, so the caller leaves its accounts out of the
|
/// another portfolio's, so the caller leaves its accounts out of the
|
||||||
|
|
@ -762,11 +761,9 @@ fn reportAccountExport(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same predicate as `.discrepancies_only` filters by, so that mode
|
if (verbose or common.hasAccountDiscrepancies(view.shown)) {
|
||||||
// always has at least one table to show.
|
|
||||||
if (verbose or common.anyAccountNeedsDetail(view.shown)) {
|
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try common.displayResults(view.shown, if (verbose) .all else .discrepancies_only, color, out);
|
try common.displayResults(view.shown, color, out);
|
||||||
} else {
|
} else {
|
||||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}: {d} {s}, no discrepancies\n", .{ label, view.shown.len, common.accountsNoun(view.shown.len) });
|
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}: {d} {s}, no discrepancies\n", .{ label, view.shown.len, common.accountsNoun(view.shown.len) });
|
||||||
}
|
}
|
||||||
|
|
@ -1136,33 +1133,17 @@ pub fn runHygieneCheck(
|
||||||
|
|
||||||
// ── Section 5: Auto-reconcile discovered files ──
|
// ── Section 5: Auto-reconcile discovered files ──
|
||||||
|
|
||||||
// Section 5's price load, kept for Section 6: the large-lot check
|
|
||||||
// needs prices for the same symbols, and loading them again re-scanned
|
|
||||||
// the candle cache and printed a second "Loaded N symbols" line - one
|
|
||||||
// that landed in the middle of the reconciliation table. Stays null
|
|
||||||
// when no export was discovered, and Section 6 loads its own.
|
|
||||||
var recon_syms: [][]const u8 = &.{};
|
|
||||||
defer allocator.free(recon_syms);
|
|
||||||
var recon_load: ?zfin.DataService.LoadAllResult = null;
|
|
||||||
defer if (recon_load) |*r| r.deinit();
|
|
||||||
|
|
||||||
if (all_files.items.len > 0) {
|
if (all_files.items.len > 0) {
|
||||||
// Flush before anything that writes to stderr. Audit, unlike the
|
// Build prices map (shared by all reconciliations)
|
||||||
// commands that load prices before printing, has already
|
|
||||||
// buffered several sections by now; stderr is unbuffered, so its
|
|
||||||
// lines otherwise land wherever the 4 KB stdout buffer last
|
|
||||||
// happened to fill - mid-line, mid-table.
|
|
||||||
try out.flush();
|
|
||||||
|
|
||||||
// Build prices map (shared by all reconciliations): the loader's
|
|
||||||
// closes, then manual prices for anything it didn't cover.
|
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
defer prices.deinit();
|
defer prices.deinit();
|
||||||
{
|
{
|
||||||
recon_syms = try portfolio.stockSymbols(allocator);
|
const pos_syms = try portfolio.stockSymbols(allocator);
|
||||||
if (recon_syms.len > 0) {
|
defer allocator.free(pos_syms);
|
||||||
recon_load = cli.loadPortfolioPrices(io, svc, recon_syms, &.{}, refresh, color);
|
if (pos_syms.len > 0) {
|
||||||
var pit = recon_load.?.prices.iterator();
|
var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, refresh, color);
|
||||||
|
defer load_result.deinit();
|
||||||
|
var pit = load_result.prices.iterator();
|
||||||
while (pit.next()) |entry| {
|
while (pit.next()) |entry| {
|
||||||
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
||||||
}
|
}
|
||||||
|
|
@ -1311,14 +1292,7 @@ pub fn runHygieneCheck(
|
||||||
// (not in a git repo). Threshold is per-account: an account's
|
// (not in a git repo). Threshold is per-account: an account's
|
||||||
// `audit_large_lot_threshold` in accounts.srf wins, otherwise the
|
// `audit_large_lot_threshold` in accounts.srf wins, otherwise the
|
||||||
// filter's built-in default applies.
|
// filter's built-in default applies.
|
||||||
//
|
if (try contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| {
|
||||||
// Reuses Section 5's prices; the pipeline loads only the symbols that
|
|
||||||
// covers nothing for (lots sold since HEAD, sibling portfolio files).
|
|
||||||
// Flush first, as before Section 5's load: that top-up, and the
|
|
||||||
// snapshot-anchor note, go to stderr.
|
|
||||||
try out.flush();
|
|
||||||
const known: ?contributions.KnownPrices = if (recon_load) |*r| .{ .requested = recon_syms, .closes = &r.prices } else null;
|
|
||||||
if (try contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh, known)) |found| {
|
|
||||||
var found_mut = found;
|
var found_mut = found;
|
||||||
defer found_mut.deinit();
|
defer found_mut.deinit();
|
||||||
|
|
||||||
|
|
@ -2831,234 +2805,3 @@ test "runHygieneCheck: discovered exports set unmapped accounts aside unless one
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account, no discrepancies\n") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account, no discrepancies\n") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account not in accounts.srf, not reconciled\n Sample Account #9012\n") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account not in accounts.srf, not reconciled\n Sample Account #9012\n") != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two Schwab accounts in one all-accounts export, written into `dir`:
|
|
||||||
/// "Sample IRA" (#1234) reconciles clean, "Sample Roth" (#5678) is 10
|
|
||||||
/// SCHD shares short. Discovered from the portfolio's own `audit/`
|
|
||||||
/// directory. `vti_extra` is appended to the VTI lot's record (e.g. a
|
|
||||||
/// typo'd field, to make Section 8 report something).
|
|
||||||
fn writeTwoAccountFixture(io: std.Io, dir: std.Io.Dir, comptime vti_extra: []const u8) !void {
|
|
||||||
try dir.writeFile(io, .{
|
|
||||||
.sub_path = "portfolio.srf",
|
|
||||||
.data = "#!srfv1\n" ++
|
|
||||||
"symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,price:num:300,account::Sample IRA" ++ vti_extra ++ "\n" ++
|
|
||||||
"symbol::SCHD,shares:num:200,open_date::2024-01-15,open_price:num:25,price:num:30,account::Sample Roth\n",
|
|
||||||
});
|
|
||||||
try dir.writeFile(io, .{
|
|
||||||
.sub_path = "accounts.srf",
|
|
||||||
.data = "#!srfv1\n" ++
|
|
||||||
"account::Sample IRA,tax_type::traditional,institution::schwab,account_number::1234\n" ++
|
|
||||||
"account::Sample Roth,tax_type::roth,institution::schwab,account_number::5678\n",
|
|
||||||
});
|
|
||||||
|
|
||||||
const header = "\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n";
|
|
||||||
const vti = "\"VTI\",\"VANGUARD TOTAL STOCK MARKET ETF\",\"1.10\",\"0.37%\",\"300.00\",\"100\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n";
|
|
||||||
// 210 shares at the broker vs 200 in the portfolio.
|
|
||||||
const schd = "\"SCHD\",\"SCHWAB US DIVIDEND EQUITY ETF\",\"0.10\",\"0.33%\",\"30.00\",\"210\",\"$21.00\",\"0.33%\",\"$6,300.00\",\"$5,250.00\",\"$1,050.00\",\"20%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n";
|
|
||||||
try dir.createDirPath(io, "audit");
|
|
||||||
try dir.writeFile(io, .{
|
|
||||||
.sub_path = "audit/all.csv",
|
|
||||||
.data = "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n" ++
|
|
||||||
"\"Sample_IRA ...1234\"\n" ++ header ++ vti ++ "\n\n" ++
|
|
||||||
"\"Sample_Roth ...5678\"\n" ++ header ++ schd,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test "runHygieneCheck: reconciliation prices come from the shared load" {
|
|
||||||
// The other fixtures price every lot by hand, which `stockSymbols`
|
|
||||||
// excludes, so Section 5 never actually loads. Here VTI has no manual
|
|
||||||
// price and only a cached close: the IRA reconciles clean only if the
|
|
||||||
// load ran and its result reached the comparison. (The same result
|
|
||||||
// is what Section 6 reuses; that hand-off is pinned in
|
|
||||||
// contributions' "values share changes at the caller's closes".)
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const io = std.testing.io;
|
|
||||||
var tmp = std.testing.tmpDir(.{});
|
|
||||||
defer tmp.cleanup();
|
|
||||||
try writeTwoAccountFixture(io, tmp.dir, "");
|
|
||||||
try tmp.dir.writeFile(io, .{
|
|
||||||
.sub_path = "portfolio.srf",
|
|
||||||
.data = "#!srfv1\n" ++
|
|
||||||
"symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,account::Sample IRA\n" ++
|
|
||||||
"symbol::SCHD,shares:num:200,open_date::2024-01-15,open_price:num:25,price:num:30,account::Sample Roth\n",
|
|
||||||
});
|
|
||||||
// `{cache_dir}/{SYMBOL}/candles_meta.srf`, unexpired: a fresh cache
|
|
||||||
// hit. `provider` has no default, so a meta without it is a miss.
|
|
||||||
try tmp.dir.createDirPath(io, "VTI");
|
|
||||||
try tmp.dir.writeFile(io, .{ .sub_path = "VTI/candles_meta.srf", .data = "#!srfv1\n#!expires=99999999999\nlast_close:num:300,last_date::2026-10-05,provider::tiingo\n" });
|
|
||||||
|
|
||||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
||||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
||||||
const dir = path_buf[0..dir_len];
|
|
||||||
|
|
||||||
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
|
|
||||||
defer svc.deinit();
|
|
||||||
var env = try std.testing.environ.createMap(allocator);
|
|
||||||
defer env.deinit();
|
|
||||||
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
|
||||||
defer allocator.free(pf_path);
|
|
||||||
|
|
||||||
// wall-clock required: discovery keeps only exports modified in the
|
|
||||||
// last 24 hours, judged against the real mtimes of the files above.
|
|
||||||
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
|
||||||
|
|
||||||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
|
||||||
defer aw.deinit();
|
|
||||||
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 10, 6), now_s, false, .never, &aw.writer);
|
|
||||||
const out = aw.written();
|
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "\n 1 more account, no discrepancies: Sample IRA\n") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, " Total: portfolio $36,000.00 brokerage $36,300.00") != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "runHygieneCheck: one bad account in an all-accounts export tables only that account unless verbose" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const io = std.testing.io;
|
|
||||||
var tmp = std.testing.tmpDir(.{});
|
|
||||||
defer tmp.cleanup();
|
|
||||||
try writeTwoAccountFixture(io, tmp.dir, "");
|
|
||||||
|
|
||||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
||||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
||||||
const dir = path_buf[0..dir_len];
|
|
||||||
|
|
||||||
// No API keys / no server / no ZFIN_AUDIT_FILES -> hermetic.
|
|
||||||
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
|
|
||||||
defer svc.deinit();
|
|
||||||
var env = try std.testing.environ.createMap(allocator);
|
|
||||||
defer env.deinit();
|
|
||||||
|
|
||||||
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
|
||||||
defer allocator.free(pf_path);
|
|
||||||
|
|
||||||
// wall-clock required: discovery keeps only exports modified in the
|
|
||||||
// last 24 hours, judged against the real mtimes of the files above.
|
|
||||||
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
|
||||||
|
|
||||||
// Routine audit: the bad account gets its table, the clean one is
|
|
||||||
// named on one line, and the totals still cover both.
|
|
||||||
{
|
|
||||||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
|
||||||
defer aw.deinit();
|
|
||||||
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 10, 6), now_s, false, .never, &aw.writer);
|
|
||||||
const out = aw.written();
|
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, " Sample Roth (Sample_Roth, #5678)\n") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Shares +10.000") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, " Sample IRA (Sample_IRA, #1234)") == null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "\n 1 more account, no discrepancies: Sample IRA\n") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, " Total: portfolio $36,000.00 brokerage $36,300.00") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, " 1 mismatch to investigate\n") != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --verbose: every account gets its table.
|
|
||||||
{
|
|
||||||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
|
||||||
defer aw.deinit();
|
|
||||||
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, true, zfin.Date.fromYmd(2026, 10, 6), now_s, false, .never, &aw.writer);
|
|
||||||
const out = aw.written();
|
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, " Sample Roth (Sample_Roth, #5678)\n") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, " Sample IRA (Sample_IRA, #1234)\n") != null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "no discrepancies: ") == null);
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, " Total: portfolio $36,000.00 brokerage $36,300.00") != null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test writer that records how many bytes had been written at each
|
|
||||||
/// `flush`, so a test can check *where* `runHygieneCheck` flushes.
|
|
||||||
/// Must not move once `writer` is in use (it finds itself via
|
|
||||||
/// `@fieldParentPtr`).
|
|
||||||
const FlushLog = struct {
|
|
||||||
gpa: std.mem.Allocator,
|
|
||||||
writer: std.Io.Writer,
|
|
||||||
written: std.ArrayList(u8) = .empty,
|
|
||||||
/// `written.items.len` at each flush.
|
|
||||||
marks: std.ArrayList(usize) = .empty,
|
|
||||||
|
|
||||||
fn init(gpa: std.mem.Allocator, buffer: []u8) FlushLog {
|
|
||||||
return .{ .gpa = gpa, .writer = .{ .vtable = &.{ .drain = drain, .flush = flush }, .buffer = buffer } };
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deinit(self: *FlushLog) void {
|
|
||||||
self.written.deinit(self.gpa);
|
|
||||||
self.marks.deinit(self.gpa);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn take(self: *FlushLog, bytes: []const u8) std.Io.Writer.Error!void {
|
|
||||||
self.written.appendSlice(self.gpa, bytes) catch return error.WriteFailed;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
|
|
||||||
const self: *FlushLog = @fieldParentPtr("writer", w);
|
|
||||||
try self.take(w.buffered());
|
|
||||||
w.end = 0;
|
|
||||||
// Every element once, except the last, which repeats `splat` times.
|
|
||||||
var n: usize = 0;
|
|
||||||
for (data, 0..) |bytes, i| {
|
|
||||||
const reps = if (i + 1 == data.len) splat else 1;
|
|
||||||
for (0..reps) |_| try self.take(bytes);
|
|
||||||
n += bytes.len * reps;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush(w: *std.Io.Writer) std.Io.Writer.Error!void {
|
|
||||||
const self: *FlushLog = @fieldParentPtr("writer", w);
|
|
||||||
try self.take(w.buffered());
|
|
||||||
w.end = 0;
|
|
||||||
self.marks.append(self.gpa, self.written.items.len) catch return error.WriteFailed;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
test "runHygieneCheck: stdout is flushed before each step that can write to stderr" {
|
|
||||||
// Price loads and the contributions pipeline write progress to
|
|
||||||
// unbuffered stderr. Unflushed, those lines landed wherever the 4 KB
|
|
||||||
// stdout buffer last filled - observed mid-row in a reconciliation
|
|
||||||
// table. Pin both flush points to the section boundaries.
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const io = std.testing.io;
|
|
||||||
var tmp = std.testing.tmpDir(.{});
|
|
||||||
defer tmp.cleanup();
|
|
||||||
// A typo'd field gives Section 8 output, so "flushed before Section
|
|
||||||
// 6" can't be satisfied by a flush at the very end.
|
|
||||||
try writeTwoAccountFixture(io, tmp.dir, ",price_dat::2026-10-01");
|
|
||||||
|
|
||||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
||||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
||||||
const dir = path_buf[0..dir_len];
|
|
||||||
|
|
||||||
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
|
|
||||||
defer svc.deinit();
|
|
||||||
var env = try std.testing.environ.createMap(allocator);
|
|
||||||
defer env.deinit();
|
|
||||||
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
|
||||||
defer allocator.free(pf_path);
|
|
||||||
|
|
||||||
// wall-clock required: discovery keeps only exports modified in the
|
|
||||||
// last 24 hours, judged against the real mtimes of the files above.
|
|
||||||
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
|
||||||
|
|
||||||
// Small buffer so the drain path is exercised too.
|
|
||||||
var buf: [256]u8 = undefined;
|
|
||||||
var log = FlushLog.init(allocator, &buf);
|
|
||||||
defer log.deinit();
|
|
||||||
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 10, 6), now_s, false, .never, &log.writer);
|
|
||||||
try log.writer.flush(); // what main does at exit
|
|
||||||
const out = log.written.items;
|
|
||||||
|
|
||||||
// Section 5: everything before the price load is out, nothing of
|
|
||||||
// the reconciliation is.
|
|
||||||
const recon = std.mem.indexOf(u8, out, "\n Reconciliation\n") orelse return error.ReconciliationMissing;
|
|
||||||
try std.testing.expect(std.mem.indexOfScalar(usize, log.marks.items, recon) != null);
|
|
||||||
|
|
||||||
// Section 6: a flush after the whole reconciliation section and
|
|
||||||
// before Section 8's report.
|
|
||||||
const recon_end = (std.mem.indexOf(u8, out, " 1 mismatch to investigate\n") orelse return error.MismatchLineMissing) + " 1 mismatch to investigate\n".len;
|
|
||||||
const section8 = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
|
|
||||||
var found = false;
|
|
||||||
for (log.marks.items) |m| {
|
|
||||||
if (m >= recon_end and m < section8) found = true;
|
|
||||||
}
|
|
||||||
try std.testing.expect(found);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -492,9 +492,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
// Attribution uses the resolved CommitSpecs so --commit-*
|
// Attribution uses the resolved CommitSpecs so --commit-*
|
||||||
// overrides + date fallbacks share one classifier. The caller
|
// overrides + date fallbacks share one classifier. The caller
|
||||||
// adapts dates to `CommitSpec.date_at_or_before` upstream.
|
// adapts dates to `CommitSpec.date_at_or_before` upstream.
|
||||||
// It reuses the live side's price load rather than repeating it.
|
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
|
||||||
const known: ?contributions.KnownPrices = if (live_data) |*ld| ld.knownPrices() else null;
|
|
||||||
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy, known);
|
|
||||||
|
|
||||||
try renderFromParts(out, color, allocator, .{
|
try renderFromParts(out, color, allocator, .{
|
||||||
.then_date = then_date,
|
.then_date = then_date,
|
||||||
|
|
@ -511,8 +509,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
var now_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, now_date);
|
var now_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, now_date);
|
||||||
defer now_side.deinit(allocator);
|
defer now_side.deinit(allocator);
|
||||||
|
|
||||||
// No live price load on this side to reuse.
|
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
|
||||||
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy, null);
|
|
||||||
|
|
||||||
try renderFromParts(out, color, allocator, .{
|
try renderFromParts(out, color, allocator, .{
|
||||||
.then_date = then_date,
|
.then_date = then_date,
|
||||||
|
|
|
||||||
|
|
@ -459,7 +459,7 @@ fn runImpl(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .verbose, null) catch return;
|
var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .verbose) catch return;
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
||||||
try printReport(out, &ctx.report, ctx.endpoints.label, color);
|
try printReport(out, &ctx.report, ctx.endpoints.label, color);
|
||||||
|
|
@ -490,28 +490,6 @@ const ReportContext = struct {
|
||||||
|
|
||||||
const PrepareError = error{PrepareFailed};
|
const PrepareError = error{PrepareFailed};
|
||||||
|
|
||||||
/// Prices a caller already loaded through `cli.loadPortfolioPrices`,
|
|
||||||
/// handed to the pipeline so it doesn't load them a second time. `audit`
|
|
||||||
/// and `compare` call this pipeline as a library right after loading
|
|
||||||
/// prices for their own output; loading again re-scanned every candle
|
|
||||||
/// file and printed a second "Loaded N symbols" line, which in `audit`
|
|
||||||
/// landed mid-table.
|
|
||||||
///
|
|
||||||
/// Only symbols missing from `requested` are loaded - in practice the
|
|
||||||
/// ones held at the before-side commit but sold since, or held in a
|
|
||||||
/// portfolio file the caller didn't load.
|
|
||||||
pub const KnownPrices = struct {
|
|
||||||
/// Every symbol the caller asked the loader for. One that is absent
|
|
||||||
/// from `closes` failed to load; asking again under the same refresh
|
|
||||||
/// policy fails the same way, so it is not retried.
|
|
||||||
requested: []const []const u8,
|
|
||||||
/// The loader's raw closes (`LoadAllResult.prices`). NOT a map that
|
|
||||||
/// `buildFallbackPrices` or a manual-price pass has filled in: this
|
|
||||||
/// pipeline applies its own fallbacks (`outflowUnitValue`), and a
|
|
||||||
/// manual or avg-cost entry posing as a close would change them.
|
|
||||||
closes: *const std.StringHashMap(f64),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Run the common pipeline - resolve endpoints, load the merged
|
/// Run the common pipeline - resolve endpoints, load the merged
|
||||||
/// portfolio at both revisions, fetch prices, build the report.
|
/// portfolio at both revisions, fetch prices, build the report.
|
||||||
///
|
///
|
||||||
|
|
@ -532,9 +510,6 @@ pub const KnownPrices = struct {
|
||||||
/// Stderr output is gated by `verbosity`: `.verbose` is the `run`
|
/// Stderr output is gated by `verbosity`: `.verbose` is the `run`
|
||||||
/// path (user sees why things failed); `.silent` is the attribution
|
/// path (user sees why things failed); `.silent` is the attribution
|
||||||
/// path (failure just means "no attribution line", don't nag).
|
/// path (failure just means "no attribution line", don't nag).
|
||||||
///
|
|
||||||
/// `known` is prices the caller has already loaded (see `KnownPrices`);
|
|
||||||
/// null loads everything.
|
|
||||||
fn prepareReport(
|
fn prepareReport(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
@ -548,7 +523,6 @@ fn prepareReport(
|
||||||
color: bool,
|
color: bool,
|
||||||
refresh: framework.RefreshPolicy,
|
refresh: framework.RefreshPolicy,
|
||||||
verbosity: Verbosity,
|
verbosity: Verbosity,
|
||||||
known: ?KnownPrices,
|
|
||||||
) PrepareError!ReportContext {
|
) PrepareError!ReportContext {
|
||||||
if (paths.len == 0) {
|
if (paths.len == 0) {
|
||||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error: No portfolio file found\n");
|
if (verbosity == .verbose) cli.stderrPrint(io, "Error: No portfolio file found\n");
|
||||||
|
|
@ -627,11 +601,12 @@ fn prepareReport(
|
||||||
sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed;
|
sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Take what the caller already loaded; load only the rest.
|
var syms: std.ArrayList([]const u8) = .empty;
|
||||||
const missing = takeKnownPrices(arena, &sym_set, known, &prices) catch return error.PrepareFailed;
|
var sit = sym_set.keyIterator();
|
||||||
|
while (sit.next()) |k| syms.append(arena, k.*) catch return error.PrepareFailed;
|
||||||
|
|
||||||
if (missing.len > 0) {
|
if (syms.items.len > 0) {
|
||||||
var load_result = cli.loadPortfolioPrices(io, svc, missing, &.{}, refresh, color);
|
var load_result = cli.loadPortfolioPrices(io, svc, syms.items, &.{}, refresh, color);
|
||||||
defer load_result.deinit();
|
defer load_result.deinit();
|
||||||
var pit = load_result.prices.iterator();
|
var pit = load_result.prices.iterator();
|
||||||
while (pit.next()) |entry| {
|
while (pit.next()) |entry| {
|
||||||
|
|
@ -717,34 +692,6 @@ fn prepareReport(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split the symbols `prepareReport` needs (`needed`) into those the
|
|
||||||
/// caller already has a load result for and those still to load. Known
|
|
||||||
/// closes are copied into `prices`; a symbol the caller asked for but
|
|
||||||
/// got no close for is dropped, not retried (see `KnownPrices`). Returns
|
|
||||||
/// the symbols to load, allocated in `arena`. With `known` null, that is
|
|
||||||
/// all of them.
|
|
||||||
fn takeKnownPrices(
|
|
||||||
arena: std.mem.Allocator,
|
|
||||||
needed: *const std.StringHashMap(void),
|
|
||||||
known: ?KnownPrices,
|
|
||||||
prices: *std.StringHashMap(f64),
|
|
||||||
) ![]const []const u8 {
|
|
||||||
var asked = std.StringHashMap(void).init(arena);
|
|
||||||
if (known) |k| for (k.requested) |sym| try asked.put(sym, {});
|
|
||||||
|
|
||||||
var missing: std.ArrayList([]const u8) = .empty;
|
|
||||||
var it = needed.keyIterator();
|
|
||||||
while (it.next()) |sym| {
|
|
||||||
if (known) |k| if (k.closes.get(sym.*)) |close| {
|
|
||||||
try prices.put(sym.*, close);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if (asked.contains(sym.*)) continue;
|
|
||||||
try missing.append(arena, sym.*);
|
|
||||||
}
|
|
||||||
return missing.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `resolveEndpoints` / `prepareReport` should print
|
/// Whether `resolveEndpoints` / `prepareReport` should print
|
||||||
/// explanatory stderr messages when the window can't be resolved. The
|
/// explanatory stderr messages when the window can't be resolved. The
|
||||||
/// main `run` command uses `.verbose` so the user sees why the command
|
/// main `run` command uses `.verbose` so the user sees why the command
|
||||||
|
|
@ -1264,8 +1211,7 @@ pub const AttributionSummary = struct {
|
||||||
///
|
///
|
||||||
/// Parameters mirror `run` but without the writer/color: no output
|
/// Parameters mirror `run` but without the writer/color: no output
|
||||||
/// is produced. Failures swallow silently via the shared
|
/// is produced. Failures swallow silently via the shared
|
||||||
/// `prepareReport` helper's `.silent` verbosity. Pass `known` when the
|
/// `prepareReport` helper's `.silent` verbosity.
|
||||||
/// caller has just loaded prices itself (live-mode `compare` has).
|
|
||||||
///
|
///
|
||||||
/// Classification logic is SOLELY in `computeReport` - this function
|
/// Classification logic is SOLELY in `computeReport` - this function
|
||||||
/// just buckets pre-classified change kinds into their totals. In
|
/// just buckets pre-classified change kinds into their totals. In
|
||||||
|
|
@ -1285,7 +1231,6 @@ pub fn computeAttributionSpec(
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
color: bool,
|
color: bool,
|
||||||
refresh: framework.RefreshPolicy,
|
refresh: framework.RefreshPolicy,
|
||||||
known: ?KnownPrices,
|
|
||||||
) ?AttributionSummary {
|
) ?AttributionSummary {
|
||||||
if (before == null and after != null) return null;
|
if (before == null and after != null) return null;
|
||||||
|
|
||||||
|
|
@ -1293,7 +1238,7 @@ pub fn computeAttributionSpec(
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
|
|
||||||
var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .silent, known) catch return null;
|
var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .silent) catch return null;
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
||||||
return summarizeAttribution(ctx);
|
return summarizeAttribution(ctx);
|
||||||
|
|
@ -1370,9 +1315,7 @@ pub const UnmatchedLargeLotSet = struct {
|
||||||
/// the lot as an external contribution or add a transfer record
|
/// the lot as an external contribution or add a transfer record
|
||||||
/// when it was really an internal movement. Works whether or not
|
/// when it was really an internal movement. Works whether or not
|
||||||
/// `transaction_log.srf` exists - when absent, every large lot
|
/// `transaction_log.srf` exists - when absent, every large lot
|
||||||
/// surfaces since nothing gets reclassified. `known` is the prices
|
/// surfaces since nothing gets reclassified.
|
||||||
/// audit loaded for its reconciliation, if it ran one (see
|
|
||||||
/// `KnownPrices`).
|
|
||||||
pub fn findUnmatchedLargeLots(
|
pub fn findUnmatchedLargeLots(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
@ -1383,7 +1326,6 @@ pub fn findUnmatchedLargeLots(
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
color: bool,
|
color: bool,
|
||||||
refresh: framework.RefreshPolicy,
|
refresh: framework.RefreshPolicy,
|
||||||
known: ?KnownPrices,
|
|
||||||
) !?UnmatchedLargeLotSet {
|
) !?UnmatchedLargeLotSet {
|
||||||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||||
errdefer arena_state.deinit();
|
errdefer arena_state.deinit();
|
||||||
|
|
@ -1397,7 +1339,7 @@ pub fn findUnmatchedLargeLots(
|
||||||
//
|
//
|
||||||
// Separate allocator here so we can tear the whole thing down
|
// Separate allocator here so we can tear the whole thing down
|
||||||
// via `arena_state.deinit` once we've copied out the descriptors.
|
// via `arena_state.deinit` once we've copied out the descriptors.
|
||||||
var ctx = prepareReport(io, allocator, arena, env, svc, paths, null, null, as_of, color, refresh, .silent, known) catch |err| {
|
var ctx = prepareReport(io, allocator, arena, env, svc, paths, null, null, as_of, color, refresh, .silent) catch |err| {
|
||||||
std.log.scoped(.contributions).debug("large-lot check skipped: no comparable window ({t})", .{err});
|
std.log.scoped(.contributions).debug("large-lot check skipped: no comparable window ({t})", .{err});
|
||||||
arena_state.deinit();
|
arena_state.deinit();
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -6948,7 +6890,7 @@ test "prepareReport: a lot archived into a sibling portfolio file is one sale" {
|
||||||
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
|
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
|
||||||
defer svc.deinit();
|
defer svc.deinit();
|
||||||
|
|
||||||
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 8, 17), false, .never, .silent, null) catch return;
|
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 8, 17), false, .never, .silent) catch return;
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
||||||
var sale: ?Change = null;
|
var sale: ?Change = null;
|
||||||
|
|
@ -7014,7 +6956,7 @@ test "prepareReport: a CD archived unchanged pays out in the commits that span i
|
||||||
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
|
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
|
||||||
defer svc.deinit();
|
defer svc.deinit();
|
||||||
|
|
||||||
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 3, 10), false, .never, .silent, null) catch return error.PrepareFailed;
|
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 3, 10), false, .never, .silent) catch return error.PrepareFailed;
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), countKind(ctx.report, .cd_matured));
|
try std.testing.expectEqual(@as(usize, 1), countKind(ctx.report, .cd_matured));
|
||||||
|
|
@ -7076,7 +7018,7 @@ test "prepareReport: a cached dividend on an opted-in account leaves attribution
|
||||||
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
|
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
|
||||||
defer svc.deinit();
|
defer svc.deinit();
|
||||||
|
|
||||||
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, null) catch return error.PrepareFailed;
|
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent) catch return error.PrepareFailed;
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 364.58), summarizeAttribution(ctx).total(), 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 364.58), summarizeAttribution(ctx).total(), 0.01);
|
||||||
|
|
@ -7110,104 +7052,13 @@ test "prepareReport: with no dividend data the report runs and counts the cash a
|
||||||
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
|
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
|
||||||
defer svc.deinit();
|
defer svc.deinit();
|
||||||
|
|
||||||
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, null) catch return error.PrepareFailed;
|
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent) catch return error.PrepareFailed;
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 470.98), summarizeAttribution(ctx).total(), 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 470.98), summarizeAttribution(ctx).total(), 0.01);
|
||||||
try std.testing.expect(cashDeltaIn(ctx.report, "Sample HSA") == null);
|
try std.testing.expect(cashDeltaIn(ctx.report, "Sample HSA") == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "takeKnownPrices: copies known closes, skips asked-but-failed, loads the rest" {
|
|
||||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
|
||||||
defer arena_state.deinit();
|
|
||||||
const arena = arena_state.allocator();
|
|
||||||
|
|
||||||
var needed = std.StringHashMap(void).init(arena);
|
|
||||||
for ([_][]const u8{ "VTI", "SCHD", "SOLD" }) |s| try needed.put(s, {});
|
|
||||||
|
|
||||||
// No caller load: everything is still to load.
|
|
||||||
{
|
|
||||||
var prices = std.StringHashMap(f64).init(arena);
|
|
||||||
const missing = try takeKnownPrices(arena, &needed, null, &prices);
|
|
||||||
try std.testing.expectEqual(@as(usize, 3), missing.len);
|
|
||||||
try std.testing.expectEqual(@as(u32, 0), prices.count());
|
|
||||||
}
|
|
||||||
|
|
||||||
// The caller asked for VTI and SCHD; SCHD failed. SOLD (held only at
|
|
||||||
// the before-side commit) was never asked for.
|
|
||||||
{
|
|
||||||
var closes = std.StringHashMap(f64).init(arena);
|
|
||||||
try closes.put("VTI", 300);
|
|
||||||
const known: KnownPrices = .{ .requested = &.{ "VTI", "SCHD" }, .closes = &closes };
|
|
||||||
var prices = std.StringHashMap(f64).init(arena);
|
|
||||||
const missing = try takeKnownPrices(arena, &needed, known, &prices);
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), missing.len);
|
|
||||||
try std.testing.expectEqualStrings("SOLD", missing[0]);
|
|
||||||
try std.testing.expectEqual(@as(f64, 300), prices.get("VTI").?);
|
|
||||||
// Failed once under this refresh policy -> not retried, no price.
|
|
||||||
try std.testing.expect(!prices.contains("SCHD"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test "prepareReport: values share changes at the caller's closes instead of loading again" {
|
|
||||||
// End to end through git. The cache is empty and the policy is
|
|
||||||
// `.never`, so a fresh load here finds nothing and a share change is
|
|
||||||
// valued at open_price; a caller-supplied close must be used instead.
|
|
||||||
if (!test_git.available(std.testing.allocator)) return;
|
|
||||||
|
|
||||||
const io = std.testing.io;
|
|
||||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
|
||||||
defer arena_state.deinit();
|
|
||||||
const allocator = arena_state.allocator();
|
|
||||||
|
|
||||||
var tmp = std.testing.tmpDir(.{});
|
|
||||||
defer tmp.cleanup();
|
|
||||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
||||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
||||||
const dir = path_buf[0..dir_len];
|
|
||||||
|
|
||||||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "#!srfv1\nsymbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,account::Sample IRA\n" });
|
|
||||||
try test_git.run(allocator, dir, null, &.{ "init", "-q" });
|
|
||||||
try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" });
|
|
||||||
try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" });
|
|
||||||
try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" });
|
|
||||||
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" });
|
|
||||||
try test_git.run(allocator, dir, "2026-09-19T12:00:00", &.{ "commit", "-q", "-m", "before" });
|
|
||||||
// Working copy: 10 more shares on the same lot.
|
|
||||||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "#!srfv1\nsymbol::VTI,shares:num:110,open_date::2024-01-15,open_price:num:200,account::Sample IRA\n" });
|
|
||||||
|
|
||||||
var env = try std.testing.environ.createMap(allocator);
|
|
||||||
defer env.deinit();
|
|
||||||
const port_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
|
||||||
const paths: []const []const u8 = &.{port_path};
|
|
||||||
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
|
|
||||||
defer svc.deinit();
|
|
||||||
|
|
||||||
const Run = struct {
|
|
||||||
fn vtiUnitValue(ctx: ReportContext) !f64 {
|
|
||||||
for (ctx.report.changes) |c| {
|
|
||||||
if (std.mem.eql(u8, c.symbol, "VTI")) return c.unit_value;
|
|
||||||
}
|
|
||||||
return error.NoVtiChange;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Without a caller load: nothing cached, so open_price.
|
|
||||||
{
|
|
||||||
var ctx = try prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, null);
|
|
||||||
defer ctx.deinit();
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 200), try Run.vtiUnitValue(ctx), 0.001);
|
|
||||||
}
|
|
||||||
// With one: the caller's close.
|
|
||||||
{
|
|
||||||
var closes = std.StringHashMap(f64).init(allocator);
|
|
||||||
try closes.put("VTI", 300);
|
|
||||||
var ctx = try prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, .{ .requested = &.{"VTI"}, .closes = &closes });
|
|
||||||
defer ctx.deinit();
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 300), try Run.vtiUnitValue(ctx), 0.001);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test "computeReport: stock open_price renormalized reclassified as edit" {
|
test "computeReport: stock open_price renormalized reclassified as edit" {
|
||||||
// Reconciliation tweak: user updates `open_price` to match the
|
// Reconciliation tweak: user updates `open_price` to match the
|
||||||
// institutional-share-class NAV, leaving everything else alone.
|
// institutional-share-class NAV, leaving everything else alone.
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ const std = @import("std");
|
||||||
const zfin = @import("../root.zig");
|
const zfin = @import("../root.zig");
|
||||||
const cli = @import("common.zig");
|
const cli = @import("common.zig");
|
||||||
const framework = @import("framework.zig");
|
const framework = @import("framework.zig");
|
||||||
const contributions = @import("contributions.zig");
|
|
||||||
const fmt = cli.fmt;
|
const fmt = cli.fmt;
|
||||||
const Date = zfin.Date;
|
const Date = zfin.Date;
|
||||||
const Money = @import("../Money.zig");
|
const Money = @import("../Money.zig");
|
||||||
|
|
@ -371,10 +370,7 @@ const AsOfResolution = struct {
|
||||||
/// `prices` is retained alongside the summary so callers that
|
/// `prices` is retained alongside the summary so callers that
|
||||||
/// need per-symbol price lookups (e.g. `compare`'s LiveSide via
|
/// need per-symbol price lookups (e.g. `compare`'s LiveSide via
|
||||||
/// `compare_core.aggregateLiveStocks`) can borrow without
|
/// `compare_core.aggregateLiveStocks`) can borrow without
|
||||||
/// re-fetching. It includes the manual and avg-cost fallbacks
|
/// re-fetching.
|
||||||
/// `buildPortfolioData` adds; `closes` is the loader's output before
|
|
||||||
/// them, for consumers that apply their own fallbacks (see
|
|
||||||
/// `knownPrices`).
|
|
||||||
///
|
///
|
||||||
/// Caller owns the fields and must call `deinit` after the helper
|
/// Caller owns the fields and must call `deinit` after the helper
|
||||||
/// returns.
|
/// returns.
|
||||||
|
|
@ -382,20 +378,12 @@ pub const LiveData = struct {
|
||||||
loaded: cli.LoadedPortfolio,
|
loaded: cli.LoadedPortfolio,
|
||||||
pf_data: cli.PortfolioData,
|
pf_data: cli.PortfolioData,
|
||||||
prices: std.StringHashMap(f64),
|
prices: std.StringHashMap(f64),
|
||||||
closes: std.StringHashMap(f64),
|
|
||||||
|
|
||||||
pub fn deinit(self: *LiveData, allocator: std.mem.Allocator) void {
|
pub fn deinit(self: *LiveData, allocator: std.mem.Allocator) void {
|
||||||
self.closes.deinit();
|
|
||||||
self.prices.deinit();
|
self.prices.deinit();
|
||||||
self.pf_data.deinit(allocator);
|
self.pf_data.deinit(allocator);
|
||||||
self.loaded.deinit(allocator);
|
self.loaded.deinit(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The price load this was built from, for the contributions
|
|
||||||
/// pipeline to reuse instead of loading again.
|
|
||||||
pub fn knownPrices(self: *const LiveData) contributions.KnownPrices {
|
|
||||||
return .{ .requested = self.loaded.syms, .closes = &self.closes };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Load the live portfolio (multi-file union-merge), fetch prices
|
/// Load the live portfolio (multi-file union-merge), fetch prices
|
||||||
|
|
@ -420,27 +408,26 @@ pub fn loadLiveData(
|
||||||
var loaded = cli.loadPortfolio(ctx, today) orelse return null;
|
var loaded = cli.loadPortfolio(ctx, today) orelse return null;
|
||||||
errdefer loaded.deinit(allocator);
|
errdefer loaded.deinit(allocator);
|
||||||
|
|
||||||
// Keep the loader's own map as `closes`; `prices` is a copy that
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
// `buildPortfolioData` fills in.
|
|
||||||
var closes = cli.loadPortfolioPrices(io, svc, loaded.syms, &.{}, ctx.globals.refresh_policy, color).prices;
|
|
||||||
errdefer closes.deinit();
|
|
||||||
var prices = try closes.clone();
|
|
||||||
errdefer prices.deinit();
|
errdefer prices.deinit();
|
||||||
|
{
|
||||||
|
var load_result = cli.loadPortfolioPrices(io, svc, loaded.syms, &.{}, ctx.globals.refresh_policy, color);
|
||||||
|
defer load_result.deinit();
|
||||||
|
var it = load_result.prices.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today) catch |err| switch (err) {
|
const pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today) catch |err| switch (err) {
|
||||||
error.NoAllocations, error.SummaryFailed => {
|
error.NoAllocations, error.SummaryFailed => {
|
||||||
cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
||||||
// A null return isn't an error return: the errdefers above
|
|
||||||
// don't run on it.
|
|
||||||
prices.deinit();
|
|
||||||
closes.deinit();
|
|
||||||
loaded.deinit(allocator);
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
else => return err,
|
else => return err,
|
||||||
};
|
};
|
||||||
|
|
||||||
return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices, .closes = closes };
|
return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-call configuration for `runBands`. Bundled because the
|
/// Per-call configuration for `runBands`. Bundled because the
|
||||||
|
|
@ -2177,12 +2164,10 @@ fn makeTestLiveData(io: std.Io, svc: *zfin.DataService, pf_path: []const u8, tod
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
errdefer prices.deinit();
|
errdefer prices.deinit();
|
||||||
try prices.put("VTI", 200.0);
|
try prices.put("VTI", 200.0);
|
||||||
var closes = try prices.clone();
|
|
||||||
errdefer closes.deinit();
|
|
||||||
|
|
||||||
const pf_data = try portfolio_loader.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today);
|
const pf_data = try portfolio_loader.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today);
|
||||||
|
|
||||||
return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices, .closes = closes };
|
return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a `history/imported_values.srf` with the given body into
|
/// Write a `history/imported_values.srf` with the given body into
|
||||||
|
|
@ -2194,37 +2179,6 @@ fn writeFixtureImported(io: std.Io, tmp: *std.testing.TmpDir, body: []const u8)
|
||||||
try hist_dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = body });
|
try hist_dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = body });
|
||||||
}
|
}
|
||||||
|
|
||||||
test "LiveData.knownPrices hands over the loader's closes, not the fallback-filled prices" {
|
|
||||||
// The contributions pipeline applies its own fallbacks, so an
|
|
||||||
// avg-cost entry posing as a close would change what it reports.
|
|
||||||
const io = std.testing.io;
|
|
||||||
var tmp = std.testing.tmpDir(.{});
|
|
||||||
defer tmp.cleanup();
|
|
||||||
var svc = makeTestSvc();
|
|
||||||
defer svc.deinit();
|
|
||||||
|
|
||||||
// VTI has a close (makeTestLiveData supplies 200); SCHD has none.
|
|
||||||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data =
|
|
||||||
\\#!srfv1
|
|
||||||
\\symbol::VTI,shares:num:100,open_date::2020-01-15,open_price:num:200,account::Sample Roth
|
|
||||||
\\symbol::SCHD,shares:num:10,open_date::2020-01-15,open_price:num:25,account::Sample Roth
|
|
||||||
\\
|
|
||||||
});
|
|
||||||
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
|
|
||||||
defer testing.allocator.free(pf);
|
|
||||||
|
|
||||||
var ld = try makeTestLiveData(io, &svc, pf, Date.fromYmd(2026, 3, 13));
|
|
||||||
defer ld.deinit(testing.allocator);
|
|
||||||
|
|
||||||
// `buildPortfolioData` filled SCHD in at its average cost...
|
|
||||||
try testing.expectApproxEqAbs(@as(f64, 25), ld.prices.get("SCHD").?, 0.001);
|
|
||||||
// ...but the hand-off carries only what the loader produced.
|
|
||||||
const known = ld.knownPrices();
|
|
||||||
try testing.expectApproxEqAbs(@as(f64, 200), known.closes.get("VTI").?, 0.001);
|
|
||||||
try testing.expect(!known.closes.contains("SCHD"));
|
|
||||||
try testing.expectEqual(ld.loaded.syms.len, known.requested.len);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "runBands: imported-only as_of scales today's composition and renders body" {
|
test "runBands: imported-only as_of scales today's composition and renders body" {
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue