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
|
||||
//! - `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)
|
||||
//!
|
||||
//! Most cash-account activity is internal flow - DRIP cash legs,
|
||||
|
|
@ -1211,6 +1239,24 @@ const Change = struct {
|
|||
/// kinds.
|
||||
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 {
|
||||
return self.delta_shares * self.unit_value;
|
||||
}
|
||||
|
|
@ -1222,15 +1268,19 @@ const Change = struct {
|
|||
/// `new_cash`, `cash_contribution`, and `cash_delta`, the
|
||||
/// matcher may have credited some of the value to a cash-
|
||||
/// destination transfer record (see `matchCashDestination`);
|
||||
/// `transfer_attributed` tracks how much. The residual is
|
||||
/// `value() - transfer_attributed`, which is what shows up in
|
||||
/// "New contributions / purchases" and the audit large-lot
|
||||
/// filter. Everyone else sees `value()` unchanged.
|
||||
/// `transfer_attributed` tracks how much. For `new_stock` /
|
||||
/// `new_cd`, `internal_funded` tracks how much of the purchase was
|
||||
/// funded by a same-account cash decrease (see
|
||||
/// `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 {
|
||||
return switch (self.kind) {
|
||||
.transfer_in, .transfer_out, .unmatched_transfer => 0,
|
||||
.partial_transfer_in => 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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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.
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
// transfer record (whose `transfer_attributed` equals
|
||||
// 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();
|
||||
if (residual <= 0) continue;
|
||||
any = true;
|
||||
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 => {
|
||||
// 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);
|
||||
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) ──
|
||||
//
|
||||
// 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 ────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 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" {
|
||||
// Reconciliation tweak: user updates `open_price` to match the
|
||||
// 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);
|
||||
}
|
||||
|
||||
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" {
|
||||
// Partial transfers leave the Change as `partial_transfer_in`,
|
||||
// which the audit filter IGNORES (only new_* kinds pass the
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue