add in-kind transfer capability
This commit is contained in:
parent
5ad353ed43
commit
7e9261f92f
5 changed files with 614 additions and 46 deletions
|
|
@ -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
22
TODO.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue