contributions to consider shares purchased with cash in account
This commit is contained in:
parent
d59555bf92
commit
0882e6321f
1 changed files with 519 additions and 6 deletions
|
|
@ -46,6 +46,34 @@
|
||||||
//! - `price_only` - same-key, only the `price::` field changed
|
//! - `price_only` - same-key, only the `price::` field changed
|
||||||
//! - `flagged` - any other edit shape (maturity_date change, etc.)
|
//! - `flagged` - any other edit shape (maturity_date change, etc.)
|
||||||
//!
|
//!
|
||||||
|
//! ### Intra-account purchase netting (`matchIntraAccountPurchases`)
|
||||||
|
//!
|
||||||
|
//! A plain buy made with cash already in the account is not a fresh
|
||||||
|
//! contribution - the cash was counted when it arrived, and investing
|
||||||
|
//! it just changes its form. In the diff such a buy is two changes on
|
||||||
|
//! the SAME account: a `new_stock` / `new_cd` lot appearing, and the
|
||||||
|
//! account's cash going down (a negative `cash_delta`, or a cash
|
||||||
|
//! `lot_removed` if the line was fully spent). `matchIntraAccountPurchases`
|
||||||
|
//! builds a per-account cash-outflow budget from those decreases and
|
||||||
|
//! draws it down against the account's `new_stock` / `new_cd` lots,
|
||||||
|
//! recording the funded amount on each Change's `internal_funded`.
|
||||||
|
//! `attributedValue()` subtracts it, so the funded portion leaves
|
||||||
|
//! attribution everywhere at once (report, per-account totals, compare,
|
||||||
|
//! audit large-lot nudge) - no `transaction_log.srf` entry required.
|
||||||
|
//!
|
||||||
|
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
|
||||||
|
//! |------------------------------------------------|--------------|------------------------|:--------------:|:--------------:|
|
||||||
|
//! | Buy fully funded by same-account cash decrease | `new_stock` | Internal purchases | no | no |
|
||||||
|
//! | Buy partly funded (cash + new money) | `new_stock` | New contributions (residual) + Internal purchases | residual | residual |
|
||||||
|
//!
|
||||||
|
//! Runs AFTER the transfer matcher so explicit `transaction_log.srf`
|
||||||
|
//! records win: cash already credited to a `transfer_out` isn't in the
|
||||||
|
//! budget, and a lot already flipped to `transfer_in` is no longer
|
||||||
|
//! `new_stock` / `new_cd`. Scope is deliberately narrow - only brand-new
|
||||||
|
//! `new_stock` / `new_cd` lots, same account. `new_drip_lot`,
|
||||||
|
//! `rollup_delta` / `drip_confirmed`, and `partial_transfer_in`
|
||||||
|
//! residuals are left untouched (see `matchIntraAccountPurchases`).
|
||||||
|
//!
|
||||||
//! ### Cash-account opt-in (`cash_is_contribution::true` in accounts.srf)
|
//! ### Cash-account opt-in (`cash_is_contribution::true` in accounts.srf)
|
||||||
//!
|
//!
|
||||||
//! Most cash-account activity is internal flow - DRIP cash legs,
|
//! Most cash-account activity is internal flow - DRIP cash legs,
|
||||||
|
|
@ -1211,6 +1239,24 @@ const Change = struct {
|
||||||
/// kinds.
|
/// kinds.
|
||||||
transfer_date: ?Date = null,
|
transfer_date: ?Date = null,
|
||||||
|
|
||||||
|
// ── Intra-account purchase netting (see `matchIntraAccountPurchases`) ────
|
||||||
|
/// Portion of this Change's `value()` funded by a decrease in the
|
||||||
|
/// SAME account's cash during the window - i.e. a plain buy of a
|
||||||
|
/// new lot using cash that was already sitting in the account
|
||||||
|
/// (cash -> security). Set only for `new_stock` / `new_cd`. The
|
||||||
|
/// matched cash leaves as a negative `cash_delta` (or a removed
|
||||||
|
/// cash lot) on the same account, so funding the purchase with it
|
||||||
|
/// is internal movement, not a fresh contribution. `attributedValue()`
|
||||||
|
/// subtracts it; a fully-funded buy nets to $0 and drops out of
|
||||||
|
/// "New contributions", the per-account totals, the compare
|
||||||
|
/// attribution line, and the audit large-lot nudge. Zero otherwise.
|
||||||
|
///
|
||||||
|
/// Distinct from `transfer_attributed` (which tracks cross-account
|
||||||
|
/// movement declared in `transaction_log.srf`); the two are
|
||||||
|
/// additive on `new_stock` / `new_cd` and never overlap because a
|
||||||
|
/// transfer-matched lot is reclassified away from those kinds.
|
||||||
|
internal_funded: f64 = 0,
|
||||||
|
|
||||||
pub fn value(self: Change) f64 {
|
pub fn value(self: Change) f64 {
|
||||||
return self.delta_shares * self.unit_value;
|
return self.delta_shares * self.unit_value;
|
||||||
}
|
}
|
||||||
|
|
@ -1222,15 +1268,19 @@ const Change = struct {
|
||||||
/// `new_cash`, `cash_contribution`, and `cash_delta`, the
|
/// `new_cash`, `cash_contribution`, and `cash_delta`, the
|
||||||
/// matcher may have credited some of the value to a cash-
|
/// matcher may have credited some of the value to a cash-
|
||||||
/// destination transfer record (see `matchCashDestination`);
|
/// destination transfer record (see `matchCashDestination`);
|
||||||
/// `transfer_attributed` tracks how much. The residual is
|
/// `transfer_attributed` tracks how much. For `new_stock` /
|
||||||
/// `value() - transfer_attributed`, which is what shows up in
|
/// `new_cd`, `internal_funded` tracks how much of the purchase was
|
||||||
/// "New contributions / purchases" and the audit large-lot
|
/// funded by a same-account cash decrease (see
|
||||||
/// filter. Everyone else sees `value()` unchanged.
|
/// `matchIntraAccountPurchases`). The residual is
|
||||||
|
/// `value() - transfer_attributed - internal_funded`, which is
|
||||||
|
/// what shows up in "New contributions / purchases" and the audit
|
||||||
|
/// large-lot filter. Everyone else sees `value()` unchanged.
|
||||||
pub fn attributedValue(self: Change) f64 {
|
pub fn attributedValue(self: Change) f64 {
|
||||||
return switch (self.kind) {
|
return switch (self.kind) {
|
||||||
.transfer_in, .transfer_out, .unmatched_transfer => 0,
|
.transfer_in, .transfer_out, .unmatched_transfer => 0,
|
||||||
.partial_transfer_in => self.value() - self.transfer_attributed,
|
.partial_transfer_in => self.value() - self.transfer_attributed,
|
||||||
.new_cash, .cash_contribution, .cash_delta => self.value() - self.transfer_attributed,
|
.new_cash, .cash_contribution, .cash_delta => self.value() - self.transfer_attributed,
|
||||||
|
.new_stock, .new_cd => self.value() - self.transfer_attributed - self.internal_funded,
|
||||||
else => self.value(),
|
else => self.value(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1800,6 +1850,16 @@ fn computeReport(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Intra-account purchase netting: a decrease in an account's cash
|
||||||
|
// (negative cash_delta or a removed cash lot) is presumed to fund
|
||||||
|
// new_stock / new_cd lots that appeared in the SAME account - a
|
||||||
|
// plain buy of existing cash, not a fresh contribution. Runs AFTER
|
||||||
|
// matchTransfers so explicit transfer records win (cash already
|
||||||
|
// claimed as a transfer_out doesn't double-fund a purchase, and a
|
||||||
|
// lot already reclassified to transfer_in is no longer new_stock /
|
||||||
|
// new_cd). See `matchIntraAccountPurchases`.
|
||||||
|
try matchIntraAccountPurchases(allocator, &changes);
|
||||||
|
|
||||||
// Build per-account totals.
|
// Build per-account totals.
|
||||||
var acct_totals = std.StringHashMap(Report.AccountTotal).init(allocator);
|
var acct_totals = std.StringHashMap(Report.AccountTotal).init(allocator);
|
||||||
|
|
||||||
|
|
@ -2440,6 +2500,91 @@ fn matchInKindTransfer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Intra-account purchase netting ───────────────────────────
|
||||||
|
|
||||||
|
/// Net same-account cash decreases against new purchase lots.
|
||||||
|
///
|
||||||
|
/// A plain buy made with cash already in the account shows up in the
|
||||||
|
/// diff as two changes on the SAME account: a `new_stock` / `new_cd`
|
||||||
|
/// lot appearing, and the account's cash going down (a negative
|
||||||
|
/// `cash_delta`, or a `lot_removed` if the cash line was fully
|
||||||
|
/// consumed). No new money entered the portfolio - the cash was
|
||||||
|
/// already counted when it arrived - so the purchase shouldn't read
|
||||||
|
/// as a fresh contribution.
|
||||||
|
///
|
||||||
|
/// This pass builds a per-account "cash outflow" budget from those
|
||||||
|
/// decreases and draws it down against the account's `new_stock` /
|
||||||
|
/// `new_cd` changes, accumulating the funded amount onto each Change's
|
||||||
|
/// `internal_funded`. `attributedValue()` subtracts it, so a fully-
|
||||||
|
/// funded buy nets to $0 (drops out of "New contributions", per-account
|
||||||
|
/// totals, the compare attribution line, and the audit large-lot
|
||||||
|
/// nudge) while a partially-funded buy keeps the unfunded residual as
|
||||||
|
/// real new money.
|
||||||
|
///
|
||||||
|
/// Runs after `matchTransfers`, so:
|
||||||
|
/// - Cash already reclassified to `transfer_out` (an outflow to a
|
||||||
|
/// declared transfer) is NOT in the budget - it can't also fund an
|
||||||
|
/// intra-account purchase.
|
||||||
|
/// - Lots already reclassified to `transfer_in` / `partial_transfer_in`
|
||||||
|
/// are no longer `new_stock` / `new_cd`, so an explicit transfer
|
||||||
|
/// record always takes priority over this automatic netting.
|
||||||
|
///
|
||||||
|
/// Scope is deliberately narrow - only brand-new `new_stock` / `new_cd`
|
||||||
|
/// lots. `new_drip_lot` (a reinvested dividend, not a cash buy),
|
||||||
|
/// `rollup_delta` / `drip_confirmed` (share adds to an existing lot),
|
||||||
|
/// and `partial_transfer_in` residuals are left untouched. Same-account
|
||||||
|
/// only; cross-account movement stays `transaction_log.srf`'s job.
|
||||||
|
///
|
||||||
|
/// Budget is drawn in change-iteration order when an account has
|
||||||
|
/// several purchase lots; the total netted is the same regardless of
|
||||||
|
/// order, but which specific lot shows a residual can vary. This
|
||||||
|
/// mirrors `matchCashDestination`'s order-dependent draw.
|
||||||
|
fn matchIntraAccountPurchases(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
changes: *std.ArrayList(Change),
|
||||||
|
) !void {
|
||||||
|
// Per-account cash outflow: magnitude of net cash that left the
|
||||||
|
// account's cash pool this window. Negative cash_delta is a partial
|
||||||
|
// spend; a removed cash lot is a fully-drained line (its dollar
|
||||||
|
// amount lives in `face_value`, since `value()` is 0 for removals).
|
||||||
|
var outflow: std.StringHashMap(f64) = .init(allocator);
|
||||||
|
defer outflow.deinit();
|
||||||
|
for (changes.items) |c| {
|
||||||
|
switch (c.kind) {
|
||||||
|
.cash_delta => {
|
||||||
|
const v = c.value();
|
||||||
|
if (v < 0) {
|
||||||
|
const gop = try outflow.getOrPut(c.account);
|
||||||
|
if (!gop.found_existing) gop.value_ptr.* = 0;
|
||||||
|
gop.value_ptr.* += -v;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.lot_removed => if (c.security_type == .cash) {
|
||||||
|
const gop = try outflow.getOrPut(c.account);
|
||||||
|
if (!gop.found_existing) gop.value_ptr.* = 0;
|
||||||
|
gop.value_ptr.* += c.face_value;
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (outflow.count() == 0) return;
|
||||||
|
|
||||||
|
// Draw each account's outflow down against its new purchase lots.
|
||||||
|
for (changes.items) |*c| {
|
||||||
|
switch (c.kind) {
|
||||||
|
.new_stock, .new_cd => {},
|
||||||
|
else => continue,
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Output ───────────────────────────────────────────────────
|
// ── Output ───────────────────────────────────────────────────
|
||||||
|
|
||||||
fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void {
|
fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void {
|
||||||
|
|
@ -2472,12 +2617,18 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co
|
||||||
// Use attributedValue so a cash lot fully covered by a
|
// Use attributedValue so a cash lot fully covered by a
|
||||||
// transfer record (whose `transfer_attributed` equals
|
// transfer record (whose `transfer_attributed` equals
|
||||||
// its `value()`) drops out, and a partially-covered
|
// its `value()`) drops out, and a partially-covered
|
||||||
// lot shows only the unattributed remainder.
|
// lot shows only the unattributed remainder. The same
|
||||||
|
// residual logic covers `new_stock` / `new_cd` partially
|
||||||
|
// funded by a same-account cash decrease (internal_funded).
|
||||||
const residual = c.attributedValue();
|
const residual = c.attributedValue();
|
||||||
if (residual <= 0) continue;
|
if (residual <= 0) continue;
|
||||||
any = true;
|
any = true;
|
||||||
new_total += residual;
|
new_total += residual;
|
||||||
try printChangeLine(out, c, color, pos_color);
|
if (c.internal_funded > 0) {
|
||||||
|
try printCashFundedResidualLine(out, c, color, pos_color, mut_color);
|
||||||
|
} else {
|
||||||
|
try printChangeLine(out, c, color, pos_color);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
.partial_transfer_in => {
|
.partial_transfer_in => {
|
||||||
// Lot partially funded by a transfer: show the residual
|
// Lot partially funded by a transfer: show the residual
|
||||||
|
|
@ -2572,6 +2723,31 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co
|
||||||
if (!any) try printNone(out, color, mut_color);
|
if (!any) try printNone(out, color, mut_color);
|
||||||
try out.writeAll("\n");
|
try out.writeAll("\n");
|
||||||
|
|
||||||
|
// ── Section: Internal purchases (cash-funded - not counted) ──
|
||||||
|
//
|
||||||
|
// new_stock / new_cd lots whose value was funded (wholly or in
|
||||||
|
// part) by a decrease in the SAME account's cash this window - a
|
||||||
|
// plain buy of existing cash, reclassified by
|
||||||
|
// `matchIntraAccountPurchases`. The funded portion is internal
|
||||||
|
// movement, not a fresh contribution, so it's shown here (muted)
|
||||||
|
// rather than counted in "New contributions". A partially-funded
|
||||||
|
// lot also appears in "New contributions" on its unfunded residual.
|
||||||
|
var any_internal = false;
|
||||||
|
for (report.changes) |c| {
|
||||||
|
if (c.internal_funded > 0) {
|
||||||
|
any_internal = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (any_internal) {
|
||||||
|
try printSection(out, "Internal purchases (cash -> securities, not counted)", color, h_color);
|
||||||
|
for (report.changes) |c| {
|
||||||
|
if (c.internal_funded <= 0) continue;
|
||||||
|
try printInternalPurchaseLine(out, c, color, mut_color);
|
||||||
|
}
|
||||||
|
try out.writeAll("\n");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Section: Transfers (matched - not counted) ──
|
// ── Section: Transfers (matched - not counted) ──
|
||||||
//
|
//
|
||||||
// Any record from `transaction_log.srf` that matched a
|
// Any record from `transaction_log.srf` that matched a
|
||||||
|
|
@ -2945,6 +3121,47 @@ fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render a `new_stock` / `new_cd` row in the "New contributions"
|
||||||
|
/// section when the lot was PARTIALLY funded by a same-account cash
|
||||||
|
/// decrease. Shows the unfunded residual (real new money) plus an
|
||||||
|
/// annotation breaking out the cash-funded portion, mirroring
|
||||||
|
/// `printPartialTransferLine`'s shape for transfers.
|
||||||
|
fn printCashFundedResidualLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8, muted: [3]u8) !void {
|
||||||
|
const acct = if (c.account.len == 0) "(no account)" else c.account;
|
||||||
|
const residual = c.attributedValue();
|
||||||
|
const lot_value = c.value();
|
||||||
|
const sym = if (c.symbol.len > 0) c.symbol else "cash";
|
||||||
|
|
||||||
|
try out.print(" {s:<14}{s:<24}", .{ sym, acct });
|
||||||
|
try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)});
|
||||||
|
try cli.printFg(
|
||||||
|
out,
|
||||||
|
color,
|
||||||
|
muted,
|
||||||
|
" (of {f} total - {f} from existing cash)\n",
|
||||||
|
.{ Money.from(lot_value), Money.from(c.internal_funded) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an "Internal purchases" row: a `new_stock` / `new_cd` lot
|
||||||
|
/// funded (wholly or in part) by a same-account cash decrease. Muted -
|
||||||
|
/// these don't count toward attribution. Shows the funded amount and,
|
||||||
|
/// when only part of the lot was cash-funded, the full lot value so
|
||||||
|
/// the unfunded residual (shown in "New contributions") reconciles.
|
||||||
|
fn printInternalPurchaseLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void {
|
||||||
|
const acct = if (c.account.len == 0) "(no account)" else c.account;
|
||||||
|
const sym = if (c.symbol.len > 0) c.symbol else "cash";
|
||||||
|
const lot_value = c.value();
|
||||||
|
|
||||||
|
try cli.setFg(out, color, muted);
|
||||||
|
try out.print(" {s:<14}{s:<24} {f} from existing cash", .{ sym, acct, Money.from(c.internal_funded) });
|
||||||
|
if (c.internal_funded + 0.005 < lot_value) {
|
||||||
|
try out.print(" (of {f} lot)", .{Money.from(lot_value)});
|
||||||
|
}
|
||||||
|
try cli.reset(out, color);
|
||||||
|
try out.writeAll("\n");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
@ -3437,6 +3654,235 @@ test "computeReport: CD open_date rewrite reclassified as edit, not new+removed"
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0), drip, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 0), drip, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Intra-account purchase netting (matchIntraAccountPurchases) ──
|
||||||
|
|
||||||
|
test "computeReport: stock bought with existing cash nets to zero contribution" {
|
||||||
|
// The user's scenario: $30k of cash already in the account is
|
||||||
|
// spent on a new stock lot. The diff shows new_stock +$30k plus a
|
||||||
|
// -$30k cash_delta on the SAME account. Netting funds the buy from
|
||||||
|
// the cash decrease, so it contributes nothing.
|
||||||
|
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{
|
||||||
|
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
|
||||||
|
var new_stock: ?Change = null;
|
||||||
|
var cash_delta: ?Change = null;
|
||||||
|
for (report.changes) |c| switch (c.kind) {
|
||||||
|
.new_stock => new_stock = c,
|
||||||
|
.cash_delta => cash_delta = c,
|
||||||
|
else => {},
|
||||||
|
};
|
||||||
|
try std.testing.expect(new_stock != null);
|
||||||
|
try std.testing.expect(cash_delta != null);
|
||||||
|
// Buy fully funded by the $30k cash decrease.
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.value(), 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.attributedValue(), 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, -30_000.0), cash_delta.?.value(), 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computeReport: stock bought with a fully-consumed cash lot (lot_removed) nets to zero" {
|
||||||
|
// Same as above but the cash line was spent in full and deleted,
|
||||||
|
// so the cash decrease surfaces as a removed cash lot rather than
|
||||||
|
// a negative cash_delta. The removed lot's dollar amount lives in
|
||||||
|
// face_value.
|
||||||
|
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{
|
||||||
|
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
|
||||||
|
var new_stock: ?Change = null;
|
||||||
|
var cash_removed = false;
|
||||||
|
for (report.changes) |c| switch (c.kind) {
|
||||||
|
.new_stock => new_stock = c,
|
||||||
|
.lot_removed => if (c.security_type == .cash) {
|
||||||
|
cash_removed = true;
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
};
|
||||||
|
try std.testing.expect(new_stock != null);
|
||||||
|
try std.testing.expect(cash_removed);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.attributedValue(), 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computeReport: buy partly funded by cash, partly new money surfaces the residual" {
|
||||||
|
// $30k existing cash spent + $20k new money into a single $50k buy.
|
||||||
|
// Only the $20k unfunded residual counts as a contribution.
|
||||||
|
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{
|
||||||
|
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
// Cash line fully spent (removed); $50k stock lot appears.
|
||||||
|
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
|
||||||
|
var new_stock: ?Change = null;
|
||||||
|
for (report.changes) |c| {
|
||||||
|
if (c.kind == .new_stock) new_stock = c;
|
||||||
|
}
|
||||||
|
try std.testing.expect(new_stock != null);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), new_stock.?.value(), 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), new_stock.?.attributedValue(), 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computeReport: cross-account cash decrease does NOT fund a purchase" {
|
||||||
|
// Cash drops in Acct A; a new stock lot appears in Acct B. These
|
||||||
|
// are different accounts, so netting does not apply - the buy
|
||||||
|
// still counts (cross-account movement is transaction_log's job).
|
||||||
|
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{
|
||||||
|
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
|
||||||
|
var new_stock: ?Change = null;
|
||||||
|
for (report.changes) |c| {
|
||||||
|
if (c.kind == .new_stock) new_stock = c;
|
||||||
|
}
|
||||||
|
try std.testing.expect(new_stock != null);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.internal_funded, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.attributedValue(), 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computeReport: new-account deposit-and-invest still counts (no prior cash)" {
|
||||||
|
// Fresh account: cash deposited and partly invested in the same
|
||||||
|
// window. The cash never existed before, so there is no decrease
|
||||||
|
// to net against - both the new cash and the new stock are real
|
||||||
|
// contributions.
|
||||||
|
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 = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
|
||||||
|
var new_money: f64 = 0;
|
||||||
|
for (report.changes) |c| switch (c.kind) {
|
||||||
|
.new_stock, .new_cash => {
|
||||||
|
new_money += c.attributedValue();
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), c.internal_funded, 0.01);
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
};
|
||||||
|
// $20k cash + $30k stock = $50k of genuinely new money.
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), new_money, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computeReport: existing-cash buy nets out of compare attribution too" {
|
||||||
|
// The classifier is the single source of truth, so the compare
|
||||||
|
// attribution line (summarizeAttribution) must agree with the
|
||||||
|
// report: a cash-funded buy contributes $0.
|
||||||
|
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{
|
||||||
|
.{ .symbol = "", .shares = 40_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "", .shares = 10_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
|
||||||
|
// Replicate summarizeAttribution's new_contributions bucket.
|
||||||
|
var new_contributions: f64 = 0;
|
||||||
|
for (report.changes) |c| switch (c.kind) {
|
||||||
|
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(),
|
||||||
|
.partial_transfer_in => new_contributions += c.attributedValue(),
|
||||||
|
else => {},
|
||||||
|
};
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_contributions, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computeReport: transfer record takes priority over intra-account netting" {
|
||||||
|
// A buy declared as a cross-account transfer (transaction_log)
|
||||||
|
// flips to transfer_in BEFORE intra-account netting runs, so it
|
||||||
|
// must not also be credited with internal_funded even if the
|
||||||
|
// destination account happens to show a cash decrease.
|
||||||
|
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{
|
||||||
|
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "", .shares = 10_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||||
|
\\#!srfv1
|
||||||
|
\\transfer::2026-05-03,type::cash,amount:num:30000,from::Sample Source,to::Sample Brokerage,dest_lot::SYM@2026-05-03
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||||||
|
.transfer_log = tlog.transfers,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (report.changes) |c| {
|
||||||
|
if (std.mem.eql(u8, c.symbol, "SYM")) {
|
||||||
|
try std.testing.expectEqual(ChangeKind.transfer_in, c.kind);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), c.internal_funded, 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
||||||
|
|
@ -5443,6 +5889,73 @@ test "collectUnmatchedLargeLots: no new lots -> empty result" {
|
||||||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "collectUnmatchedLargeLots: buy funded by same-account cash is silent" {
|
||||||
|
// The user's audit complaint: a $30k stock lot bought with cash
|
||||||
|
// already in the account. The -$30k cash_delta funds it, netting
|
||||||
|
// its attributedValue to $0, so audit must not flag it - no
|
||||||
|
// transaction_log.srf entry required.
|
||||||
|
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{
|
||||||
|
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectUnmatchedLargeLots: partly cash-funded buy surfaces residual only" {
|
||||||
|
// $50k buy, $30k from existing cash -> $20k of new money remains,
|
||||||
|
// which is above the $10k threshold and SHOULD still surface.
|
||||||
|
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{
|
||||||
|
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), lots.len);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), lots[0].value, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectUnmatchedLargeLots: partial cash-funded residual below threshold is silent" {
|
||||||
|
// $35k buy, $30k from existing cash -> $5k residual, below the
|
||||||
|
// $10k threshold -> silent.
|
||||||
|
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{
|
||||||
|
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 70, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||||
|
}
|
||||||
|
|
||||||
test "collectUnmatchedLargeLots: partial transfer still flags residual? No - full lot value counts" {
|
test "collectUnmatchedLargeLots: partial transfer still flags residual? No - full lot value counts" {
|
||||||
// Partial transfers leave the Change as `partial_transfer_in`,
|
// Partial transfers leave the Change as `partial_transfer_in`,
|
||||||
// which the audit filter IGNORES (only new_* kinds pass the
|
// which the audit filter IGNORES (only new_* kinds pass the
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue