add in-kind transfer capability

This commit is contained in:
Emil Lerch 2026-06-25 13:49:29 -07:00
parent 5ad353ed43
commit 7e9261f92f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 614 additions and 46 deletions

View file

@ -46,7 +46,7 @@ repos:
- id: test
name: Run zig build test
entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=77"]
args: ["build", "coverage", "-Dcoverage-threshold=78"]
language: system
types: [file]
pass_filenames: false

22
TODO.md
View file

@ -168,28 +168,6 @@ opts ESPP/HSA accounts into cash-based attribution.
Related: ESPP-style accrual blind spot in the "Audit: manual-check
accounts mechanism" section above.
## In-kind transfer support (`type::in_kind`) - priority MEDIUM
`transaction_log.srf` parses `type::in_kind` records but the
contributions matcher always rejects them with "in-kind transfers
not yet supported in v1." In-kind movements need per-symbol
matching across accounts: an in-kind transfer of 100 VTI shares
from Acct A to Acct B shows up as `lot_removed` on A + `new_stock`
on B (or a `rollup_delta` share increase if B already had a VTI
lot), neither of which can be matched by the current
amount-based cash matcher.
Proposed: a second pass in `matchTransfers` that iterates
`type::in_kind` records and looks for same-symbol matches across
`lot_removed` on `from` + `new_stock`/`rollup_delta` on `to`
within the window. Gated on share-count and open_price sanity so
a partial transfer doesn't false-positive against an unrelated
edit.
Driver: when the user starts moving positions between accounts
directly (e.g. Roth conversion of already-held shares, 401k ->
rollover IRA in-kind) rather than liquidating and re-buying.
## Torn SRF files from server sync (root cause unknown)
**Status:** Root cause still unidentified. We have mitigations and

View file

@ -442,8 +442,10 @@ fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool {
///
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
/// (or cash_contribution) destinations use `dest_lot::cash`. The
/// template always uses `type::cash` since `type::in_kind` is
/// rejected downstream in v1.
/// template defaults to `type::cash` (cash moved in, then invested -
/// the common case). If the securities themselves were moved between
/// accounts, change it to `type::in_kind` and fill in the `from::`
/// account that the shares left.
fn printLargeLotWarning(
out: *std.Io.Writer,
lot: contributions.UnmatchedLargeLot,

View file

@ -130,12 +130,20 @@
//! a reason string; the transfer amount
//! stays out of attribution either way.
//!
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
//! |-----------------------------------------------|------------------------|------------------|:--------------:|:--------------:|
//! | Lot fully funded by transfer | `transfer_in` | Transfers | no | no |
//! | Lot partially funded by transfer | `partial_transfer_in` | New contributions (residual) + Transfers | residual | residual |
//! | Sending-side `lot_removed` / `cash_delta` | `transfer_out` | Transfers | no | no |
//! | Record with no match (bad dest, in_kind, ...) | `unmatched_transfer` | Flagged | no | no |
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
//! |-------------------------------------------------|--------------------------------|------------------------------------------|:--------------:|:--------------:|
//! | Lot fully funded by transfer | `transfer_in` | Transfers | no | no |
//! | Lot partially funded by transfer | `partial_transfer_in` | New contributions (residual) + Transfers | residual | residual |
//! | Sending-side `lot_removed` / `cash_delta` | `transfer_out` | Transfers | no | no |
//! | In-kind securities moved between accounts | `transfer_in` + `transfer_out` | Transfers | no | no |
//! | Record with no match (bad dest, mismatch, ...) | `unmatched_transfer` | Flagged | no | no |
//!
//! `type::cash` records and `type::in_kind` records take different
//! matching passes. Cash records match a cash budget / pooled
//! destination (see `matchCashDestination` / `matchLotDestination`).
//! In-kind records (securities moved without cash changing hands)
//! pair a source share-removal Change against a destination share-
//! addition Change on a per-symbol basis (see `matchInKindTransfer`).
//!
//! Cash-destination records don't flip the original `cash_delta` /
//! `new_cash` Change (a single cash delta can be drained by
@ -1863,6 +1871,20 @@ fn computeReport(
/// dollars for cash lots) without masking real discrepancies.
const transfer_amount_tolerance: f64 = 1.0;
/// Relative share-count tolerance for confirming an in-kind transfer:
/// the shares removed on the `from` account must match the shares
/// added on the `to` account to within this fraction of the larger
/// of the two. Brokerages sometimes liquidate a fractional share on
/// an in-kind move (whole shares transfer, the residual fraction is
/// swept to cash), so a small relative drift is expected. Mirrors the
/// 1% direct-indexing drift tolerance used elsewhere in the diff.
const in_kind_share_rel_tolerance: f64 = 0.01;
/// Absolute floor for the in-kind share-count tolerance, so a small
/// transfer (a one-share move) still gets a sane float-rounding
/// epsilon even though 1% of one share is tiny.
const in_kind_share_abs_tolerance: f64 = 0.01;
/// Compute the slice of transfer records that are NEW in `after`
/// relative to `before` - i.e. records the matcher should consider
/// for the current diff. Records present in both logs are skipped
@ -1904,8 +1926,11 @@ fn diffTransferLogs(
/// recorded transfer. Walks `report.changes` and the in-window
/// transfer records together:
///
/// - For each record with `type::cash` (v1 only; `in_kind` always
/// emits `unmatched_transfer`):
/// - For each record with `type::in_kind`: pair a source share-
/// removal Change on `from` against a destination share-addition
/// Change on `to`, per-symbol (see `matchInKindTransfer`).
///
/// - For each record with `type::cash`:
/// - If `dest_lot` is a specific lot (`SYMBOL@DATE`): find the
/// matching `new_stock` / `new_drip_lot` / `new_cash` /
/// `new_cd` / `cash_contribution` Change with the same
@ -1989,7 +2014,12 @@ fn matchTransfers(
// after-side `transaction_log.srf` vs. the before-side).
for (records) |rec| {
if (rec.type == .in_kind) {
try appendUnmatched(allocator, changes, rec, "in-kind transfers not yet supported in v1");
// In-kind transfers move securities, not cash, so they
// take a separate per-symbol matching pass (source
// `lot_removed`/`drip_negative` + destination
// `new_stock`/`new_drip_lot`/`rollup_delta`) rather than
// the cash budget / from-side path below.
try matchInKindTransfer(allocator, changes, rec);
continue;
}
@ -2277,6 +2307,139 @@ fn tryMatchFromSide(
};
}
/// Match a `type::in_kind` transfer record: securities moved between
/// accounts without any cash changing hands. Unlike the cash path,
/// both the source and destination are lot-level Changes:
///
/// - **Destination** (required): a `new_stock` / `new_drip_lot`
/// (the shares landed as a fresh lot on `to`) or a `rollup_delta`
/// (the shares were added to an existing `to` lot). Identified by
/// the record's `dest_lot::SYMBOL@DATE`. Reclassified to
/// `transfer_in` so it contributes $0 to attribution - it's
/// internal movement, not new money.
/// - **Source** (best-effort): a `lot_removed` / `drip_negative` on
/// the `from` account with the same symbol. Reclassified to
/// `transfer_out`. A missing source is NOT an error - the sending
/// account may be untracked (an external rollover origin), the
/// same tolerance the cash from-side path extends.
///
/// When both sides are present the shares removed must match the
/// shares added to within `in_kind_share_*_tolerance`; a mismatch
/// means the declared transfer doesn't cleanly correspond to the
/// portfolio diff (wrong symbol, unexpected partial fill) and is
/// surfaced as `unmatched_transfer` rather than silently swallowing a
/// real contribution.
///
/// `amount` on an in-kind record is informational - the moved value
/// comes from the destination lot's own `value()` (shares x cost
/// basis), which is what `transfer_attributed` records for display.
/// In-kind movements have no "residual new money" concept (no cash
/// funded them), so there is no `partial_transfer_in` outcome: a
/// destination Change is either fully a transfer or not one at all.
fn matchInKindTransfer(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
rec: transaction_log.TransferRecord,
) !void {
const dl: transaction_log.DestLot.LotRef = switch (rec.dest_lot) {
.lot => |l| l,
.cash => {
try appendUnmatched(allocator, changes, rec, "in-kind transfer requires a SYMBOL@DATE destination lot, not cash");
return;
},
};
// Destination (required)
// First share-addition Change on the `to` account for this
// symbol. Keyed on (account, symbol, kind); open_date is
// consulted only for display (Change doesn't carry it for
// rollup_delta). Once matched, a Change is flipped to transfer_in,
// so a second record naming the same lot won't re-match it (it's
// no longer a destination kind) and falls through to not-found.
var dest_idx: ?usize = null;
for (changes.items, 0..) |c, i| {
if (!std.mem.eql(u8, c.account, rec.to)) continue;
if (!std.mem.eql(u8, c.symbol, dl.symbol)) continue;
switch (c.kind) {
.new_stock, .new_drip_lot, .rollup_delta => {},
else => continue,
}
dest_idx = i;
break;
}
const di = dest_idx orelse {
const buf = try std.fmt.allocPrint(
allocator,
"in-kind destination lot {s}@ not found on account {s} (expected a new lot or share increase; an earlier record may have claimed it)",
.{ dl.symbol, rec.to },
);
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
return;
};
// Source (best-effort)
// First share-removal Change on the `from` account for this
// symbol. A missing source is not an error - the sending account
// may be untracked (external rollover origin).
var src_idx: ?usize = null;
for (changes.items, 0..) |c, i| {
if (!std.mem.eql(u8, c.account, rec.from)) continue;
if (!std.mem.eql(u8, c.symbol, dl.symbol)) continue;
switch (c.kind) {
.lot_removed, .drip_negative => {},
else => continue,
}
src_idx = i;
break;
}
// Share-count gate (only when both sides are present)
if (src_idx) |si| {
const dest_shares = @abs(changes.items[di].delta_shares);
const src_shares = @abs(changes.items[si].delta_shares);
const tol = @max(
in_kind_share_abs_tolerance,
in_kind_share_rel_tolerance * @max(dest_shares, src_shares),
);
if (@abs(dest_shares - src_shares) > tol) {
const buf = try std.fmt.allocPrint(
allocator,
"in-kind share mismatch for {s}: {d:.4} removed from {s} vs {d:.4} added to {s}",
.{ dl.symbol, src_shares, rec.from, dest_shares, rec.to },
);
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
return;
}
}
// Reclassify
// Dupe owned strings before mutating so an allocation failure
// doesn't leave a half-rewritten Change. The moved value is the
// destination lot's own value (shares x cost basis), not the
// record's `amount` (which is an informational annotation).
const note_copy: ?[]const u8 = if (rec.note) |n| try allocator.dupe(u8, n) else null;
const from_copy = try allocator.dupe(u8, rec.from);
const moved_value = changes.items[di].value();
const dest = &changes.items[di];
dest.kind = .transfer_in;
dest.transfer_attributed = moved_value;
dest.transfer_note = note_copy;
dest.transfer_from = from_copy;
dest.transfer_date = rec.transfer;
if (src_idx) |si| {
const src = &changes.items[si];
src.kind = .transfer_out;
// The from-side display reads `transfer_attributed` for the
// moved value and `account` for the sending account; it does
// not read `transfer_from` (see `printTransferLine`).
src.transfer_attributed = moved_value;
src.transfer_date = rec.transfer;
}
}
// Output
fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void {
@ -4494,7 +4657,11 @@ test "matchTransfers: same-day multi-cash records drain a single cash_delta" {
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
}
test "matchTransfers: type::in_kind always emits unmatched" {
test "matchInKindTransfer: happy path - new lot on dest, lot_removed on source" {
// Roth-conversion shape: 80 shares of SYM move in-kind from
// Acct A (tracked) to Acct B. A's lot disappears (lot_removed),
// B gains a fresh lot (new_stock). Both flip to transfer kinds
// and contribute $0 to attribution.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
@ -4502,8 +4669,9 @@ test "matchTransfers: type::in_kind always emits unmatched" {
defer prices.deinit();
try prices.put("SYM", 100.0);
// Even if a matching lot exists, in_kind is rejected in v1.
const before = [_]Lot{};
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
@ -4518,18 +4686,435 @@ test "matchTransfers: type::in_kind always emits unmatched" {
.transfer_log = tlog.transfers,
});
// new_stock stays as new_stock (not flipped); unmatched appended.
var n_transfer_in: usize = 0;
var n_transfer_out: usize = 0;
var n_new_stock: usize = 0;
var n_lot_removed: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => n_transfer_in += 1,
.transfer_out => n_transfer_out += 1,
.new_stock => n_new_stock += 1,
.lot_removed => n_lot_removed += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
try std.testing.expectEqual(@as(usize, 1), n_transfer_out);
try std.testing.expectEqual(@as(usize, 0), n_new_stock);
try std.testing.expectEqual(@as(usize, 0), n_lot_removed);
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
// Dest moved value is the lot's own value (80 x $100 = $8,000).
for (report.changes) |c| {
if (c.kind == .transfer_in) {
try std.testing.expectApproxEqAbs(@as(f64, 8000.0), c.transfer_attributed, 0.01);
try std.testing.expectEqualStrings("Acct A", c.transfer_from.?);
}
}
// No new money on either account.
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01);
}
test "matchInKindTransfer: into existing dest lot (rollup_delta) flips to transfer_in" {
// B already held SYM; the in-kind add shows up as a rollup_delta
// (share increase on the existing lot), not a new_stock.
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("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" },
.{ .symbol = "SYM", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 80, .account = "Acct B" },
};
const after = [_]Lot{
// Acct A's lot gone; Acct B's lot grew by 100 shares (same key).
.{ .symbol = "SYM", .shares = 150, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 80, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:10000,from::Acct A,to::Acct B,dest_lot::SYM@2024-01-01
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_transfer_in: usize = 0;
var n_transfer_out: usize = 0;
var n_rollup: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => n_transfer_in += 1,
.transfer_out => n_transfer_out += 1,
.rollup_delta => n_rollup += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
try std.testing.expectEqual(@as(usize, 1), n_transfer_out);
try std.testing.expectEqual(@as(usize, 0), n_rollup);
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
// Rollup share delta is no longer counted on Acct B.
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.rollup, 0.01);
}
test "matchInKindTransfer: partial move (drip_negative source) matches" {
// 40 of A's 100 SYM shares move to B. A's lot shrinks
// (drip_negative); B gains a new lot. Share counts match.
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("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" },
.{ .symbol = "SYM", .shares = 40, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 90, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:4000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_transfer_in: usize = 0;
var n_transfer_out: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => n_transfer_in += 1,
.transfer_out => n_transfer_out += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
try std.testing.expectEqual(@as(usize, 1), n_transfer_out);
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01);
}
test "matchInKindTransfer: untracked source still flips destination" {
// The `from` account isn't in the portfolio (external rollover
// origin). The destination still reclassifies to transfer_in -
// the user declared the move, so it isn't new external 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("SYM", 100.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::External Rollover,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_transfer_in: usize = 0;
var n_transfer_out: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => n_transfer_in += 1,
.transfer_out => n_transfer_out += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
try std.testing.expectEqual(@as(usize, 0), n_transfer_out); // no tracked source
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01);
}
test "matchInKindTransfer: share-count mismatch emits unmatched, leaves Changes counted" {
// A removes 100 SYM but B only gains 73 SYM - the declared
// transfer doesn't cleanly correspond to the diff. The matcher
// refuses to pair them: both Changes keep their base kinds and an
// unmatched_transfer is flagged.
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("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 73, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:10000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_new_stock: usize = 0;
var n_lot_removed: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock => n_new_stock += 1,
.lot_removed => n_lot_removed += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
// Base classifications survive; the bad record is surfaced.
try std.testing.expectEqual(@as(usize, 1), n_new_stock);
try std.testing.expectEqual(@as(usize, 1), n_lot_removed);
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
}
test "matchInKindTransfer: cash destination is rejected as unmatched" {
// An in-kind record must name a SYMBOL@DATE lot. `dest_lot::cash`
// is nonsensical for a securities transfer.
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();
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "cash", .shares = 8000, .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::in_kind,amount:num:8000,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,
});
var n_unmatched: usize = 0;
var unmatched_note: ?[]const u8 = null;
for (report.changes) |c| {
if (c.kind == .unmatched_transfer) {
n_unmatched += 1;
unmatched_note = c.transfer_note;
}
}
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
try std.testing.expect(unmatched_note != null);
try std.testing.expect(std.mem.indexOf(u8, unmatched_note.?, "not cash") != null);
}
test "matchInKindTransfer: destination lot not found emits unmatched" {
// The record names SYM but no SYM lot appeared on the `to`
// account (typo, or the lot didn't materialize this diff). The
// base diff is left untouched and the record is flagged.
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("SYM", 100.0);
const before = [_]Lot{};
const after = [_]Lot{
// A SYM lot landed on Acct C, not the record's `to` (Acct B).
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct C" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_new_stock: usize = 0;
var n_unmatched: usize = 0;
var unmatched_note: ?[]const u8 = null;
for (report.changes) |c| switch (c.kind) {
.new_stock => n_new_stock += 1,
.unmatched_transfer => {
n_unmatched += 1;
unmatched_note = c.transfer_note;
},
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_new_stock); // Acct C lot untouched
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
try std.testing.expect(unmatched_note != null);
try std.testing.expect(std.mem.indexOf(u8, unmatched_note.?, "not found") != null);
}
test "matchInKindTransfer: duplicate record - first matches, second unmatched" {
// Two in-kind records name the same destination lot but the diff
// only shows one share-addition. The first claims it (flipped to
// transfer_in); the second can't re-match the now-reclassified
// Change and is flagged.
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("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_transfer_in: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => n_transfer_in += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
}
test "printReport: in-kind transfer renders in Transfers section, out of totals" {
// End-to-end through the display layer: a report mixing a real
// new contribution, an in-kind transfer (in + out), a cash delta,
// and an unmatched removal should render every section and keep
// the transferred securities out of the grand total.
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("SYM", 100.0);
try prices.put("NEWX", 200.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Sample IRA" },
.{ .symbol = "OLDX", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50, .account = "Sample HSA" },
.{ .symbol = "cash", .shares = 1000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Sample Roth IRA" },
.{ .symbol = "NEWX", .shares = 5, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 200, .account = "Sample Brokerage" },
.{ .symbol = "cash", .shares = 1500, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Sample IRA,to::Sample Roth IRA,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var aw: std.Io.Writer.Allocating = .init(allocator);
const out = &aw.writer;
try printReport(out, &report, "test window", false);
const text = aw.written();
// Sections present.
try std.testing.expect(std.mem.indexOf(u8, text, "New contributions / purchases") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "Transfers (matched - not counted)") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "Cash deltas") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "Flagged for review") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "Summary by account") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "Grand total") != null);
// The real new purchase and the unmatched removal show up.
try std.testing.expect(std.mem.indexOf(u8, text, "NEWX") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "OLDX") != null);
// The in-kind transfer's sending account is cross-referenced in
// the Transfers section.
try std.testing.expect(std.mem.indexOf(u8, text, "Sample IRA") != null);
// Grand total = NEWX purchase ($1,000) only; the $8,000 in-kind
// SYM move and the $500 cash delta don't count as contributions.
try std.testing.expect(std.mem.indexOf(u8, text, "New contributions / purchases: $1,000.00") != null);
}
test "printReport: no changes detected renders a single line" {
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();
const before = [_]Lot{};
const after = [_]Lot{};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var aw: std.Io.Writer.Allocating = .init(allocator);
const out = &aw.writer;
try printReport(out, &report, "test window", false);
const text = aw.written();
try std.testing.expect(std.mem.indexOf(u8, text, "No changes detected") != null);
}
test "printReport: color=true emits ANSI escapes" {
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("NEWX", 200.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "NEWX", .shares = 5, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 200, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var aw: std.Io.Writer.Allocating = .init(allocator);
const out = &aw.writer;
try printReport(out, &report, "test window", true);
const text = aw.written();
// ESC (0x1b) appears when color is on.
try std.testing.expect(std.mem.indexOfScalar(u8, text, 0x1b) != null);
}
test "matchTransfers: back-dated record matches regardless of date" {
// The matcher itself is date-agnostic now - the caller (typically
// `prepareReport` via `diffTransferLogs`) is responsible for

View file

@ -46,9 +46,11 @@
//!
//! - Only `transfer::` records (no buys/sells/dividends - those stay
//! inferred from the portfolio diff).
//! - Only `type::cash` is wired downstream. `type::in_kind` parses
//! successfully but is rejected by the contributions matcher with
//! an "in-kind transfers not yet supported" message.
//! - Both `type::cash` and `type::in_kind` are wired into the
//! contributions matcher. Cash records match against a cash budget /
//! pooled destination; in-kind records (securities moved without
//! cash changing hands) match a source share-removal against a
//! destination share-addition, per-symbol.
//! - No historical reconstruction - forward-looking only.
//!
//! See `REPORT.md` §5 for the full usage guide and
@ -61,10 +63,11 @@ const Date = @import("../Date.zig");
const logger = std.log.scoped(.transaction_log);
/// Kind of transfer. Only `cash` is wired into the contributions
/// classifier in v1. `in_kind` parses successfully so the file format
/// is forward-compatible, but the matcher will reject records with
/// this type until per-symbol in-kind matching is implemented.
/// Kind of transfer. Both kinds are wired into the contributions
/// classifier: `cash` matches against the destination account's cash
/// budget / pooled cash activity, while `in_kind` pairs a source
/// share-removal against a destination share-addition for the same
/// symbol across the `from` and `to` accounts.
pub const TransferType = enum {
cash,
in_kind,
@ -626,7 +629,7 @@ test "parseTransactionLogFile: type defaults to cash when elided" {
try testing.expectEqual(TransferType.cash, log.transfers[0].type);
}
test "parseTransactionLogFile: type::in_kind parses but is preserved (rejected downstream)" {
test "parseTransactionLogFile: type::in_kind parses and preserves its type" {
var log = try parseTransactionLogFile(testing.allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:50000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03