match transfers if moved cash->cash and stock purchased in same period
This commit is contained in:
parent
87a0447b59
commit
3dd72c2e3e
1 changed files with 174 additions and 29 deletions
|
|
@ -2088,6 +2088,13 @@ fn matchTransfers(
|
||||||
// order. Underflow past tolerance -> unmatched_transfer.
|
// order. Underflow past tolerance -> unmatched_transfer.
|
||||||
var cash_budget: std.StringHashMap(f64) = .init(allocator);
|
var cash_budget: std.StringHashMap(f64) = .init(allocator);
|
||||||
defer cash_budget.deinit();
|
defer cash_budget.deinit();
|
||||||
|
|
||||||
|
// Shortfalls carried out of the cash-destination path, per destination
|
||||||
|
// account, drawn down against new lots once every record has been seen.
|
||||||
|
// Deferred to the end so a second transfer into the same account can add to
|
||||||
|
// the same budget before any of it is spent.
|
||||||
|
var transfer_funding: std.StringHashMap(FundingShortfall) = .init(allocator);
|
||||||
|
defer transfer_funding.deinit();
|
||||||
for (changes.items) |c| {
|
for (changes.items) |c| {
|
||||||
const v = c.value();
|
const v = c.value();
|
||||||
switch (c.kind) {
|
switch (c.kind) {
|
||||||
|
|
@ -2125,12 +2132,16 @@ fn matchTransfers(
|
||||||
try matchLotDestination(allocator, changes, &consumed_lot_idx, rec, dl);
|
try matchLotDestination(allocator, changes, &consumed_lot_idx, rec, dl);
|
||||||
},
|
},
|
||||||
.cash => {
|
.cash => {
|
||||||
try matchCashDestination(allocator, changes, &cash_budget, cash_attributed_by_account, rec);
|
try matchCashDestination(allocator, changes, &cash_budget, cash_attributed_by_account, &transfer_funding, rec);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
tryMatchFromSide(changes, rec);
|
tryMatchFromSide(changes, rec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After every record, so multiple transfers into one account pool their
|
||||||
|
// funding before it is drawn against that account's purchases.
|
||||||
|
try matchTransferFundedPurchases(allocator, changes, &transfer_funding);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a synthetic `unmatched_transfer` Change carrying the record's
|
/// Append a synthetic `unmatched_transfer` Change carrying the record's
|
||||||
|
|
@ -2292,31 +2303,44 @@ fn matchCashDestination(
|
||||||
changes: *std.ArrayList(Change),
|
changes: *std.ArrayList(Change),
|
||||||
cash_budget: *std.StringHashMap(f64),
|
cash_budget: *std.StringHashMap(f64),
|
||||||
cash_attributed_by_account: *std.StringHashMap(f64),
|
cash_attributed_by_account: *std.StringHashMap(f64),
|
||||||
|
transfer_funding: *std.StringHashMap(FundingShortfall),
|
||||||
rec: transaction_log.TransferRecord,
|
rec: transaction_log.TransferRecord,
|
||||||
) !void {
|
) !void {
|
||||||
const budget_entry = cash_budget.getPtr(rec.to);
|
const budget_entry = cash_budget.getPtr(rec.to);
|
||||||
const available = if (budget_entry) |p| p.* else 0.0;
|
const available = @max(0.0, if (budget_entry) |p| p.* else 0.0);
|
||||||
if (available < rec.amount - transfer_amount_tolerance) {
|
|
||||||
const buf = try std.fmt.allocPrint(
|
// Credit whatever cash actually showed up, and carry the rest as a funding
|
||||||
allocator,
|
// budget for the destination's new lots.
|
||||||
"destination cash increase ${d:.2} insufficient for transfer ${d:.2}",
|
//
|
||||||
.{ available, rec.amount },
|
// This used to bail out entirely when the cash increase fell short, which
|
||||||
);
|
// meant a transfer whose cash was invested inside the same window credited
|
||||||
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
|
// nothing at all and every purchase it funded read as new money. The cash
|
||||||
return;
|
// is genuinely absent from the snapshot in that case - it arrived and left
|
||||||
|
// between two commits - so the shortfall is expected, not a discrepancy.
|
||||||
|
// See `matchTransferFundedPurchases`, which draws it down and flags only
|
||||||
|
// what the account's new lots cannot absorb.
|
||||||
|
const credited = @min(available, rec.amount);
|
||||||
|
const shortfall = rec.amount - credited;
|
||||||
|
if (shortfall > transfer_amount_tolerance) {
|
||||||
|
const gop = try transfer_funding.getOrPut(rec.to);
|
||||||
|
if (!gop.found_existing) gop.value_ptr.* = .{ .rec = rec };
|
||||||
|
gop.value_ptr.*.amount += shortfall;
|
||||||
|
gop.value_ptr.*.declared += rec.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw from the budget. Running remainder stays on the budget
|
// Draw from the budget. Running remainder stays on the budget
|
||||||
// so later records on the same account see the correct
|
// so later records on the same account see the correct
|
||||||
// capacity.
|
// capacity.
|
||||||
if (budget_entry) |p| p.* -= rec.amount;
|
if (budget_entry) |p| p.* -= credited;
|
||||||
|
|
||||||
// Accumulate into per-account attribution bucket. The per-
|
// Accumulate into per-account attribution bucket. The per-
|
||||||
// account totals pass subtracts this from cash-side totals so
|
// account totals pass subtracts this from cash-side totals so
|
||||||
// transferred cash doesn't double-count.
|
// transferred cash doesn't double-count.
|
||||||
const gop = try cash_attributed_by_account.getOrPut(rec.to);
|
const gop = try cash_attributed_by_account.getOrPut(rec.to);
|
||||||
if (!gop.found_existing) gop.value_ptr.* = 0;
|
if (!gop.found_existing) gop.value_ptr.* = 0;
|
||||||
gop.value_ptr.* += rec.amount;
|
// Only the cash that was actually observed; the rest is attributed to the
|
||||||
|
// new lots instead, so adding the full amount here would double-count.
|
||||||
|
gop.value_ptr.* += credited;
|
||||||
|
|
||||||
// Distribute the record amount across the destination account's
|
// Distribute the record amount across the destination account's
|
||||||
// cash-side Changes by accumulating into each Change's
|
// cash-side Changes by accumulating into each Change's
|
||||||
|
|
@ -2335,7 +2359,7 @@ fn matchCashDestination(
|
||||||
// pass continues to use `cash_attributed_by_account` for its
|
// pass continues to use `cash_attributed_by_account` for its
|
||||||
// per-account math - the two views agree because the same
|
// per-account math - the two views agree because the same
|
||||||
// amount is subtracted on both sides.
|
// amount is subtracted on both sides.
|
||||||
var remaining = rec.amount;
|
var remaining = credited;
|
||||||
for (changes.items) |*c| {
|
for (changes.items) |*c| {
|
||||||
if (remaining <= 0) break;
|
if (remaining <= 0) break;
|
||||||
if (!std.mem.eql(u8, c.account, rec.to)) continue;
|
if (!std.mem.eql(u8, c.account, rec.to)) continue;
|
||||||
|
|
@ -2576,6 +2600,79 @@ fn matchInKindTransfer(
|
||||||
/// several purchase lots; the total netted is the same regardless of
|
/// several purchase lots; the total netted is the same regardless of
|
||||||
/// order, but which specific lot shows a residual can vary. This
|
/// order, but which specific lot shows a residual can vary. This
|
||||||
/// mirrors `matchCashDestination`'s order-dependent draw.
|
/// mirrors `matchCashDestination`'s order-dependent draw.
|
||||||
|
/// Draw `budget` down against the new purchase lots in `account`, marking the
|
||||||
|
/// funded portion on each. Returns whatever the account's lots could not
|
||||||
|
/// absorb.
|
||||||
|
///
|
||||||
|
/// Shared by the two things that can fund a purchase without it being new
|
||||||
|
/// money: cash that visibly left the same account, and a declared transfer
|
||||||
|
/// whose cash was spent before it could be observed. The drawdown is identical;
|
||||||
|
/// only the meaning of a leftover differs, which is why the callers handle the
|
||||||
|
/// return value differently rather than this function deciding.
|
||||||
|
fn drawDownAgainstNewLots(changes: *std.ArrayList(Change), account: []const u8, budget: f64) f64 {
|
||||||
|
var remaining = budget;
|
||||||
|
for (changes.items) |*c| {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
switch (c.kind) {
|
||||||
|
.new_stock, .new_cd => {},
|
||||||
|
else => continue,
|
||||||
|
}
|
||||||
|
if (!std.mem.eql(u8, c.account, account)) continue;
|
||||||
|
const unattributed = c.attributedValue();
|
||||||
|
if (unattributed <= 0) continue;
|
||||||
|
const draw = @min(unattributed, remaining);
|
||||||
|
c.internal_funded += draw;
|
||||||
|
remaining -= draw;
|
||||||
|
}
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attribute purchases funded by a declared transfer whose cash never appeared
|
||||||
|
/// in a snapshot.
|
||||||
|
///
|
||||||
|
/// A `transfer` record says money moved from A to B. `matchCashDestination`
|
||||||
|
/// credits it against an observed cash increase in B - but when the cash is
|
||||||
|
/// invested inside the same reconcile window, no snapshot ever contains it: the
|
||||||
|
/// diff sees new security lots in B and a few dollars of leftover cash. The
|
||||||
|
/// transfer then failed its cash check and the purchases counted as fresh
|
||||||
|
/// money, which is how one 401(k)-to-BrokerageLink move reported $738,814 of
|
||||||
|
/// contributions that were nothing of the kind.
|
||||||
|
///
|
||||||
|
/// So the shortfall becomes a funding budget for that account's new lots -
|
||||||
|
/// exactly what `matchIntraAccountPurchases` does with an observed cash
|
||||||
|
/// decrease, seeded from the operator's declaration instead of from an
|
||||||
|
/// observation. Anything the lots cannot absorb is still flagged: a transfer
|
||||||
|
/// claiming more than the destination gained is a real discrepancy and must not
|
||||||
|
/// be silently swallowed.
|
||||||
|
fn matchTransferFundedPurchases(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
changes: *std.ArrayList(Change),
|
||||||
|
funding: *std.StringHashMap(FundingShortfall),
|
||||||
|
) !void {
|
||||||
|
var it = funding.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
const account = entry.key_ptr.*;
|
||||||
|
const sf = entry.value_ptr.*;
|
||||||
|
if (sf.amount <= transfer_amount_tolerance) continue;
|
||||||
|
const leftover = drawDownAgainstNewLots(changes, account, sf.amount);
|
||||||
|
if (leftover <= transfer_amount_tolerance) continue;
|
||||||
|
const buf = try std.fmt.allocPrint(
|
||||||
|
allocator,
|
||||||
|
"transfer of ${d:.2} exceeds the destination's cash increase and new lots by ${d:.2}",
|
||||||
|
.{ sf.declared, leftover },
|
||||||
|
);
|
||||||
|
try appendUnmatchedWithOwnedNote(allocator, changes, sf.rec, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-account transfer shortfall, plus the record it came from so an
|
||||||
|
/// unabsorbed remainder can be reported against the right transfer.
|
||||||
|
const FundingShortfall = struct {
|
||||||
|
amount: f64 = 0,
|
||||||
|
declared: f64 = 0,
|
||||||
|
rec: transaction_log.TransferRecord,
|
||||||
|
};
|
||||||
|
|
||||||
fn matchIntraAccountPurchases(
|
fn matchIntraAccountPurchases(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
changes: *std.ArrayList(Change),
|
changes: *std.ArrayList(Change),
|
||||||
|
|
@ -2606,19 +2703,12 @@ fn matchIntraAccountPurchases(
|
||||||
}
|
}
|
||||||
if (outflow.count() == 0) return;
|
if (outflow.count() == 0) return;
|
||||||
|
|
||||||
// Draw each account's outflow down against its new purchase lots.
|
// Draw each account's outflow down against its new purchase lots. A
|
||||||
for (changes.items) |*c| {
|
// leftover here is unremarkable - cash can leave an account for reasons
|
||||||
switch (c.kind) {
|
// other than buying something - so it is simply discarded.
|
||||||
.new_stock, .new_cd => {},
|
var oit = outflow.iterator();
|
||||||
else => continue,
|
while (oit.next()) |e| {
|
||||||
}
|
_ = drawDownAgainstNewLots(changes, e.key_ptr.*, e.value_ptr.*);
|
||||||
const budget = outflow.getPtr(c.account) orelse continue;
|
|
||||||
if (budget.* <= 0) continue;
|
|
||||||
const unattributed = c.attributedValue(); // value() minus any prior attribution
|
|
||||||
if (unattributed <= 0) continue;
|
|
||||||
const draw = @min(unattributed, budget.*);
|
|
||||||
c.internal_funded += draw;
|
|
||||||
budget.* -= draw;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5068,7 +5158,56 @@ test "matchTransfers: amount exceeds lot value emits unmatched" {
|
||||||
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
|
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "matchTransfers: cash insufficient emits unmatched" {
|
test "matchTransfers: a transfer spent on securities before any snapshot is not new money" {
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const allocator = arena_state.allocator();
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("VOO", 100.0);
|
||||||
|
|
||||||
|
// The real 2026-08 shape, scaled down. $50k moved into an account and was
|
||||||
|
// invested the same week, so no snapshot ever contains the cash: the diff
|
||||||
|
// sees a new security lot plus the few dollars that did not get spent.
|
||||||
|
const before = [_]Lot{};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "VOO", .shares = 499, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 100.0, .account = "Acct B" },
|
||||||
|
.{ .symbol = "cash", .shares = 100, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||||
|
\\#!srfv1
|
||||||
|
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Acct A,to::Acct B,dest_lot::cash
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||||||
|
.transfer_log = tlog.transfers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing new entered the portfolio: $49,900 of VOO plus $100 of leftover
|
||||||
|
// cash is exactly the $50,000 that moved. Before this was handled, the
|
||||||
|
// purchase counted as a fresh contribution - the mechanism that reported
|
||||||
|
// $738,814 of contributions for a 401(k)-to-BrokerageLink move.
|
||||||
|
const t = report.account_totals.get("Acct B").?;
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
|
||||||
|
|
||||||
|
// And it is not reported as a discrepancy either, because it is not one:
|
||||||
|
// the destination gained precisely what the record declared.
|
||||||
|
for (report.changes) |c| {
|
||||||
|
try std.testing.expect(c.kind != .unmatched_transfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The purchase is attributed as internally funded rather than being
|
||||||
|
// dropped, so it still shows under "Internal purchases".
|
||||||
|
var funded: f64 = 0;
|
||||||
|
for (report.changes) |c| {
|
||||||
|
if (c.kind == .new_stock) funded += c.internal_funded;
|
||||||
|
}
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 49900.0), funded, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "matchTransfers: a short cash increase credits what arrived and flags the gap" {
|
||||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const allocator = arena_state.allocator();
|
const allocator = arena_state.allocator();
|
||||||
|
|
@ -5091,14 +5230,20 @@ test "matchTransfers: cash insufficient emits unmatched" {
|
||||||
.transfer_log = tlog.transfers,
|
.transfer_log = tlog.transfers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The $2k the destination never gained is still a real discrepancy.
|
||||||
var n_unmatched: usize = 0;
|
var n_unmatched: usize = 0;
|
||||||
for (report.changes) |c| if (c.kind == .unmatched_transfer) {
|
for (report.changes) |c| if (c.kind == .unmatched_transfer) {
|
||||||
n_unmatched += 1;
|
n_unmatched += 1;
|
||||||
};
|
};
|
||||||
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
|
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
|
||||||
// new_cash stays unchanged; $3k still counts as new_money.
|
|
||||||
|
// But the $3k that DID arrive is transferred money, not new money.
|
||||||
|
// Previously the whole record was abandoned when the amounts disagreed,
|
||||||
|
// so a correct partial attribution was discarded and the $3k was reported
|
||||||
|
// as a fresh contribution - which it demonstrably is not, since a transfer
|
||||||
|
// record says where it came from.
|
||||||
const t = report.account_totals.get("Acct B").?;
|
const t = report.account_totals.get("Acct B").?;
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 3000.0), t.new_money, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "matchTransfers: same-day multi-cash records drain a single cash_delta" {
|
test "matchTransfers: same-day multi-cash records drain a single cash_delta" {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue