8847 lines
402 KiB
Zig
8847 lines
402 KiB
Zig
//! `zfin contributions` - show money added to the portfolio since the
|
||
//! last recorded state in git.
|
||
//!
|
||
//! Four modes:
|
||
//! - No flags (default):
|
||
//! - dirty working tree: HEAD vs working copy
|
||
//! - clean working tree: HEAD~1 vs HEAD (review last commit)
|
||
//! - `--since <DATE>`: commit-at-or-before(DATE) vs HEAD (or working copy if dirty)
|
||
//! - `--since <D1> --until <D2>`: commit-at-or-before(D1) vs commit-at-or-before(D2)
|
||
//! - `--until <DATE>` alone: rejected; window is ambiguous
|
||
//!
|
||
//! The `--since` / `--until` flags use `commitAtOrBeforeDate` in
|
||
//! `src/git.zig`, which runs `git log --until=<DATE> -1 -- portfolio.srf`
|
||
//! to pick the most recent commit at or before the requested date.
|
||
//! Relative forms (1M, 3Q, 1Y) are also accepted - parsed by
|
||
//! `cli.parseAsOfDate` and resolved to an absolute date before being
|
||
//! passed in.
|
||
//!
|
||
//! ## Classification matrix
|
||
//!
|
||
//! Every lot-level change gets exactly one `ChangeKind` assigned at
|
||
//! diff time in `computeReport`. Downstream consumers (section printer,
|
||
//! per-account summary, `computeAttributionSpec` used by `compare`) all
|
||
//! read the pre-classified kinds - there is no post-hoc reclassification
|
||
//! in any consumer. Single point of truth so the grand total in
|
||
//! `zfin contributions` and the attribution line in `zfin compare`
|
||
//! always agree over the same window.
|
||
//!
|
||
//! ### Base classifications (same for every account)
|
||
//!
|
||
//! - `new_stock` - stock lot appeared (drip::false)
|
||
//! - `new_drip_lot` - stock lot appeared (drip::true -> confirmed DRIP)
|
||
//! - `new_cash` - cash lot appeared (fresh line, not a balance bump)
|
||
//! - `new_cd` - CD opened
|
||
//! - `new_option` - option opened
|
||
//! - `drip_confirmed` - same-key drip::true stock lot, Δshares > 0
|
||
//! - `rollup_delta` - same-key drip::false stock lot, Δshares > 0
|
||
//! (ambiguous: DRIP or contribution)
|
||
//! - `drip_negative` - same-key stock lot, Δshares < 0 (unusual)
|
||
//! - `cash_delta` - same-key cash lot, Δshares, default noise
|
||
//! - `cd_matured` - CD disappeared, maturity_date ≤ today
|
||
//! - `cd_removed_early` - CD disappeared, maturity_date > today
|
||
//! - `lot_removed` - stock/cash/option lot disappeared
|
||
//! - `lot_edited` - secondary-key match across broken strict keys
|
||
//! (open_date/open_price/symbol-alias rewrite)
|
||
//! - `price_only` - same-key, only the `price::` field changed
|
||
//! - `flagged` - any other edit shape (maturity_date change, etc.)
|
||
//!
|
||
//! ### Intra-account netting (`matchIntraAccountPurchases`)
|
||
//!
|
||
//! Money that was already inside an account is not a fresh
|
||
//! contribution - it just changed form. Two shapes of that, both
|
||
//! showing up as changes on the SAME account:
|
||
//!
|
||
//! A plain buy made with cash already in the 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).
|
||
//!
|
||
//! A reallocation - sell A, buy B in the same account: a valued
|
||
//! outflow (`lot_removed`, or `drip_negative` for a partial sale) and
|
||
//! one or more new lots. Sale proceeds that were spent fund the new
|
||
//! lots; proceeds still sitting in cash at window end instead cancel a
|
||
//! `cash_contribution`, whose "cash arriving is new money" premise is
|
||
//! false for a sale. See `matchIntraAccountPurchases` for the
|
||
//! spent/resting split and why it is load-bearing.
|
||
//!
|
||
//! Either way the funded amount is recorded on the Change's
|
||
//! `internal_funded` and `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 |
|
||
//! | Buy funded by a same-account security sale | `new_stock` | Internal purchases | no | no |
|
||
//! | Sale proceeds parked in cash on a `cash_is_contribution` account | `cash_contribution` | Internal purchases | no | no |
|
||
//!
|
||
//! 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 on the destination side is deliberately
|
||
//! narrow - only brand-new `new_stock` / `new_cd` lots and
|
||
//! `cash_contribution`, 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,
|
||
//! dividend credits, CD coupons, settlement sweeps - which is why
|
||
//! `cash_delta` is noise by default. But for payroll-adjacent
|
||
//! accounts (ESPP accrual, direct 401k cash deposits, HSA employer
|
||
//! contributions), a positive cash movement IS the contribution.
|
||
//! The `cash_is_contribution::true` flag in `accounts.srf` opts an
|
||
//! account into "positive cash_delta = real contribution" semantics.
|
||
//!
|
||
//! Opted-IN accounts:
|
||
//!
|
||
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
|
||
//! |-------------------------------|--------------------|----------------------|:--------------:|:--------------:|
|
||
//! | Brand-new cash lot appears | `new_cash` | New contributions | yes | yes |
|
||
//! | Existing cash, balance up | `cash_contribution`| New contributions | yes | yes |
|
||
//! | Existing cash, balance down | `cash_delta` | Cash deltas (raw) | no | no |
|
||
//! | Cash lot fully removed | `lot_removed` | Flagged for review | no | no |
|
||
//!
|
||
//! Opted-OUT accounts (default):
|
||
//!
|
||
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
|
||
//! |-------------------------------|--------------------|----------------------|:--------------:|:--------------:|
|
||
//! | Brand-new cash lot appears | `new_cash` | New contributions | yes | yes |
|
||
//! | Existing cash, balance moves | `cash_delta` | Cash deltas (raw) | no | no |
|
||
//! | Cash lot fully removed | `lot_removed` | Flagged for review | no | no |
|
||
//!
|
||
//! The asymmetry on negative Δ for opted-in accounts is intentional:
|
||
//! a withdrawal from ESPP/HSA is rare and semantically different
|
||
//! from "a contribution of negative money." The branch for that
|
||
//! (withdraw-as-negative-contribution) is one `if (delta < 0)` in
|
||
//! `computeReport` if/when the need arises.
|
||
//!
|
||
//! ### Direct-indexing accounts (`direct_indexing::true` in accounts.srf)
|
||
//!
|
||
//! Direct-indexing proxies hold a basket of underlying stocks
|
||
//! tracked as a single benchmark via `ticker::`. The basket
|
||
//! naturally drifts against the benchmark week-to-week (tracking
|
||
//! error) and the user rebalances periodically - producing small
|
||
//! share-count adjustments that aren't real money flow.
|
||
//!
|
||
//! When a lot in a flagged account goes through `detectEdits`
|
||
//! (strict-key broken but secondary key matches), the residual
|
||
//! share-delta tolerance is loosened from 0.01% to 1%. The identity
|
||
//! match still collapses to `lot_edited`; residuals under 1% are
|
||
//! suppressed entirely instead of surfacing as `rollup_delta` /
|
||
//! `drip_negative`. Real contributions to direct-indexing accounts
|
||
//! (e.g. a $100k buy-in on a multi-million basket = ~1.2%) still
|
||
//! surface because they're above the tolerance.
|
||
//!
|
||
//! The `zfin audit` command uses the same flag for its companion
|
||
//! behavior: emit a `price_ratio` adjustment suggestion for these
|
||
//! lots even though their ratio is 1.0, bridging the brokerage-vs-
|
||
//! portfolio value gap that accumulates from tracking error.
|
||
//!
|
||
//! ### Transfer reclassification (`transaction_log.srf`)
|
||
//!
|
||
//! Records in `transaction_log.srf` flag internal money movement
|
||
//! between accounts the user owns. When present, the matcher runs
|
||
//! after Pass 1/Pass 2 and flips destination/source Changes to
|
||
//! dedicated transfer kinds that contribute $0 to attribution -
|
||
//! fixing the double-count where a transfer's destination lot would
|
||
//! otherwise read as a fresh external contribution.
|
||
//!
|
||
//! Four reclassification kinds emerge from the matcher:
|
||
//!
|
||
//! - `transfer_in` - destination lot (or cash-dest pool)
|
||
//! fully attributed to a transfer.
|
||
//! Replaces the base `new_*` kind.
|
||
//! - `partial_transfer_in` - destination lot partially attributed.
|
||
//! Residual (`value() - transfer_attributed`)
|
||
//! still counts toward attribution as
|
||
//! "pre-existing cash that funded the
|
||
//! rest of the lot."
|
||
//! - `transfer_out` - sending-side match (negative
|
||
//! `cash_delta` or `lot_removed`). Best-
|
||
//! effort: a missing `from` side is
|
||
//! silent, not an error (the sending
|
||
//! account may not be in portfolio.srf).
|
||
//! - `unmatched_transfer` - record that couldn't be matched.
|
||
//! Surfaces in the Flagged section with
|
||
//! 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 |
|
||
//! | 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
|
||
//! multiple records, which `kind` can't represent). Instead the
|
||
//! matcher appends a synthetic `transfer_in` Change for the
|
||
//! Transfers section and accumulates the attributed amount onto each
|
||
//! consumed Change's `transfer_attributed` field. `attributedValue()`
|
||
//! then reports the unattributed residual, which is what the
|
||
//! per-account totals, the contributions sections, and audit's
|
||
//! large-lot filter all consume - one mechanism, so no two views of
|
||
//! the same attribution can drift apart.
|
||
//!
|
||
//! ### Which records does the matcher consider?
|
||
//!
|
||
//! The matcher runs against the records that are NEW in the after-side
|
||
//! `transaction_log.srf` relative to the before-side. Concretely:
|
||
//! `prepareReport` loads the file at both `before_rev` and the
|
||
//! after-side (working copy or `after_rev`), parses each, and passes
|
||
//! the set difference (`after - before`, by `TransferRecord.eql`) to
|
||
//! the matcher. See `diffTransferLogs`.
|
||
//!
|
||
//! Why not filter by `transfer::DATE` against the diff's git
|
||
//! timestamp window? Because the user's natural workflow is to
|
||
//! record a transfer days, weeks, or months after the actual
|
||
//! transaction date. A back-dated record is the rule, not the
|
||
//! exception. The original date-window filter rejected those
|
||
//! back-dated records and produced "unmatched contribution" noise
|
||
//! the user couldn't quiet without changing the record's date
|
||
//! to fall inside the diff window - a workaround that destroyed
|
||
//! the historical accuracy of `transaction_log.srf`.
|
||
//!
|
||
//! Editing a previously-recorded transfer (e.g. fixing a typo in
|
||
//! `from::`) produces an old-form record in `before` and a new-form
|
||
//! record in `after`. The old form silently drops out (its diff cycle
|
||
//! is over); the new form is treated as a fresh record and re-pairs
|
||
//! against the current diff. If no matching destination Change exists
|
||
//! in the current diff (because the lot was added in an earlier
|
||
//! commit), the record surfaces as `unmatched_transfer` - accept the
|
||
//! noise or undo the edit.
|
||
//!
|
||
//! The matcher also composes with `direct_indexing::true`: a transfer
|
||
//! into a direct-indexing account matches normally on the destination
|
||
//! lot; subsequent tracking-error drift on that lot is still swallowed
|
||
//! by the direct-indexing tolerance. Different problems, different
|
||
//! passes.
|
||
//!
|
||
//! ## Other architecture notes
|
||
//!
|
||
//! Relies on: portfolio.srf being tracked in a git repo, and the `git`
|
||
//! executable existing on PATH. We never rely on comments; maturity is
|
||
//! decided from the Lot.maturity_date field, not from the file's form.
|
||
|
||
const std = @import("std");
|
||
const zfin = @import("../root.zig");
|
||
const cli = @import("common.zig");
|
||
const git = @import("../git.zig");
|
||
const framework = @import("framework.zig");
|
||
const TimeRange = @import("TimeRange.zig");
|
||
const analysis = @import("../analytics/analysis.zig");
|
||
const transaction_log = @import("../models/transaction_log.zig");
|
||
const portfolio_loader = @import("../portfolio_loader.zig");
|
||
const history = @import("../history.zig");
|
||
const test_git = @import("../testutil/git.zig");
|
||
const Money = @import("../Money.zig");
|
||
const Date = zfin.Date;
|
||
const Lot = zfin.Lot;
|
||
const LotType = @import("../models/portfolio.zig").LotType;
|
||
|
||
// ── Public entry point ───────────────────────────────────────
|
||
|
||
/// Resolved endpoints for the contributions diff: the before/after
|
||
/// commit range (from `git.resolveCommitRange`) plus the
|
||
/// human-readable label for the report header.
|
||
const Endpoints = struct {
|
||
range: git.CommitRange,
|
||
label: []const u8,
|
||
};
|
||
|
||
pub const ParsedArgs = struct {
|
||
before: ?git.CommitSpec = null,
|
||
after: ?git.CommitSpec = null,
|
||
};
|
||
|
||
pub const meta: framework.Meta = .{
|
||
.name = "contributions",
|
||
.group = .timeseries,
|
||
.synopsis = "Show money added since the last recorded state in git",
|
||
.help =
|
||
\\Usage: zfin contributions [opts]
|
||
\\
|
||
\\Show contributions, withdrawals, and lot-level changes between
|
||
\\two points in the portfolio's git history. Four modes:
|
||
\\
|
||
\\ No flags (default):
|
||
\\ dirty working tree: HEAD vs working copy
|
||
\\ clean working tree: HEAD~1 vs HEAD (review last commit)
|
||
\\ --since <DATE> the commit recording DATE's snapshot vs
|
||
\\ HEAD (or working copy when dirty)
|
||
\\ --since <D1> --until <D2> the commits recording each snapshot
|
||
\\ --until <DATE> alone rejected; window is ambiguous
|
||
\\
|
||
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
|
||
\\
|
||
\\A date resolves to the commit that recorded `history/DATE-portfolio.srf`,
|
||
\\which is the same anchor `zfin compare` uses - so the two agree on what a
|
||
\\given week means. With no snapshot committed for that date it falls back to
|
||
\\commit-at-or-before(DATE) and says so.
|
||
\\
|
||
\\Options:
|
||
\\ --since <DATE> Earliest side (the commit recording
|
||
\\ that date's snapshot).
|
||
\\ --until <DATE> Latest side. Pair with --since.
|
||
\\ --commit-before <SPEC> Pin the before commit directly. Same
|
||
\\ grammar as --commit-after, minus
|
||
\\ `working`. Useful when you committed
|
||
\\ after your review date.
|
||
\\ --commit-after <SPEC> Pin the after commit. SPEC accepts
|
||
\\ YYYY-MM-DD, relative (1W/1M/1Q/1Y),
|
||
\\ HEAD, HEAD~N, hex SHA, or `working`
|
||
\\ for the working copy.
|
||
\\
|
||
\\--since and --commit-before describe the same axis; pass at most
|
||
\\one. Same for --until and --commit-after.
|
||
\\
|
||
,
|
||
.uppercase_first_arg = false,
|
||
.user_errors = error{ DuplicateEndpoint, InvalidArg, PrepareFailed, ResolveFailed, UnexpectedArg },
|
||
};
|
||
|
||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||
const io = ctx.io;
|
||
const today = ctx.today;
|
||
const allocator = ctx.allocator;
|
||
|
||
const tr_result = TimeRange.parse(io, allocator, today, cmd_args, .{
|
||
.accept_since = true,
|
||
.accept_until = true,
|
||
.accept_commit_before = true,
|
||
.accept_commit_after = true,
|
||
}) catch |err| switch (err) {
|
||
error.MissingValue,
|
||
error.InvalidValue,
|
||
error.WorkingCopyOnBeforeSide,
|
||
error.LiveNotAllowed,
|
||
=> return error.InvalidArg,
|
||
error.DuplicateEndpoint, error.RepeatedFlag => return error.DuplicateEndpoint,
|
||
error.OutOfMemory => return error.OutOfMemory,
|
||
};
|
||
defer allocator.free(tr_result.consumed);
|
||
|
||
// Reject any tokens TimeRange didn't consume - contributions has
|
||
// no other flags or positionals.
|
||
var consumed_set = std.AutoHashMap(usize, void).init(allocator);
|
||
defer consumed_set.deinit();
|
||
for (tr_result.consumed) |idx| try consumed_set.put(idx, {});
|
||
for (cmd_args, 0..) |a, i| {
|
||
if (consumed_set.contains(i)) continue;
|
||
cli.stderrPrint(io, "Error: unexpected argument to 'contributions': ");
|
||
cli.stderrPrint(io, a);
|
||
cli.stderrPrint(io, "\n");
|
||
return error.UnexpectedArg;
|
||
}
|
||
|
||
// Translate Endpoint to CommitSpec. `--since` produces a date
|
||
// endpoint; `--commit-before` a commit_spec endpoint. Map both
|
||
// to the same CommitSpec union the existing run() expects.
|
||
//
|
||
// A date becomes `.snapshot_add`, NOT `.date_at_or_before`, so this command
|
||
// resolves a window the same way `compare` does. They used to disagree, and
|
||
// the disagreement was load-bearing in the worst way: `compare`'s uncounted
|
||
// line says "see `zfin contributions`" and REPORT_RUNBOOK sends you to
|
||
// `zfin contributions --since 1W` to explain `compare`'s own contributions
|
||
// figure - while `commitAtOrBeforeDate` could land on a commit BEFORE the
|
||
// reconcile that the snapshot-anchored side had already stepped past. The two
|
||
// then reported different money for the same requested week.
|
||
//
|
||
// Safe as a default because `.snapshot_add` degrades to exactly the old
|
||
// `commitAtOrBeforeDate` behaviour whenever no snapshot for that date was
|
||
// committed (see `git.resolveSpec`), so any date without a snapshot resolves
|
||
// bit-for-bit as before. Where a snapshot DOES exist it also inherits the
|
||
// anchor verification and its warning, which the date path never had.
|
||
var parsed: ParsedArgs = .{};
|
||
if (tr_result.range.before) |ep| switch (ep) {
|
||
.date => |d| parsed.before = .{ .snapshot_add = d },
|
||
.commit_spec => |s| parsed.before = s,
|
||
.live => unreachable,
|
||
};
|
||
if (tr_result.range.after) |ep| switch (ep) {
|
||
.date => |d| parsed.after = .{ .snapshot_add = d },
|
||
.commit_spec => |s| parsed.after = s,
|
||
.live => unreachable,
|
||
};
|
||
|
||
// Validate `--since on or before --until` ordering for the date form, matching
|
||
// legacy behavior. Only checkable when BOTH sides carry a date - a `--commit-*`
|
||
// ref is opaque until git resolves it.
|
||
//
|
||
// Goes through `specDate` rather than testing for one variant. It used to read
|
||
// `b == .date_at_or_before`, which silently stopped rejecting inverted windows
|
||
// the moment a date started producing `.snapshot_add`.
|
||
if (parsed.before) |b| if (parsed.after) |a| {
|
||
if (specDate(b)) |bd| if (specDate(a)) |ad| {
|
||
if (bd.days > ad.days) {
|
||
cli.stderrPrint(io, "Error: --since must be on or before --until.\n");
|
||
return error.InvalidArg;
|
||
}
|
||
};
|
||
};
|
||
return parsed;
|
||
}
|
||
|
||
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||
const svc = ctx.svc orelse return error.MissingDataService;
|
||
const io = ctx.io;
|
||
const allocator = ctx.allocator;
|
||
const out = ctx.out;
|
||
const color = ctx.color;
|
||
const as_of = ctx.today;
|
||
const before = parsed.before;
|
||
const after = parsed.after;
|
||
|
||
var pf = ctx.resolvePortfolioPaths() catch {
|
||
cli.stderrPrint(io, "Error: could not resolve portfolio file(s).\n");
|
||
return;
|
||
};
|
||
defer pf.deinit();
|
||
if (pf.paths.len == 0) {
|
||
cli.stderrPrint(io, "Error: No portfolio file found\n");
|
||
return;
|
||
}
|
||
|
||
return runImpl(io, allocator, ctx.environ_map, svc, pf.paths, before, after, as_of, color, ctx.globals.refresh_policy, out);
|
||
}
|
||
|
||
fn runImpl(
|
||
io: std.Io,
|
||
allocator: std.mem.Allocator,
|
||
env: *const std.process.Environ.Map,
|
||
svc: *zfin.DataService,
|
||
paths: []const []const u8,
|
||
before: ?git.CommitSpec,
|
||
after: ?git.CommitSpec,
|
||
as_of: Date,
|
||
color: bool,
|
||
refresh: framework.RefreshPolicy,
|
||
out: *std.Io.Writer,
|
||
) !void {
|
||
// Arena for all transient allocations: git subprocess buffers, duped path
|
||
// strings, the snapshot blobs, the symbol set, price map, and the Report
|
||
// itself. Portfolio objects use the base allocator (they own their own
|
||
// deinit). One defer cleans everything up at once.
|
||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
// Enforce the "an `after` with no `before` is ambiguous" rule at
|
||
// the entry point so `resolveEndpoints`/`git.resolveCommitRangeSpec`
|
||
// can assume the invariant. The legacy no-flag path passes both
|
||
// as null and falls through to HEAD~1..HEAD / HEAD..WC.
|
||
if (before == null and after != null) {
|
||
cli.stderrPrint(io, "Error: --until / --commit-after requires --since / --commit-before.\n");
|
||
return;
|
||
}
|
||
|
||
var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .verbose) catch return;
|
||
defer ctx.deinit();
|
||
|
||
try printReport(out, &ctx.report, ctx.endpoints.label, color);
|
||
try out.flush();
|
||
}
|
||
|
||
/// Shared pipeline context: everything `run` and `computeAttributionSpec`
|
||
/// both need from the git-backed diff.
|
||
///
|
||
/// Owned fields split across two allocators:
|
||
/// - `before_pf` / `after_pf` are merged multi-file loads on the base
|
||
/// allocator (their own `deinit` frees lots and file bytes).
|
||
/// - `endpoints` and `report` live in the supplied arena; the arena's
|
||
/// own `deinit` cleans them up.
|
||
/// `deinit` releases only the base-allocator-owned pieces.
|
||
const ReportContext = struct {
|
||
endpoints: Endpoints,
|
||
before_pf: portfolio_loader.LoadedPortfolio,
|
||
after_pf: portfolio_loader.LoadedPortfolio,
|
||
report: Report,
|
||
allocator: std.mem.Allocator,
|
||
|
||
fn deinit(self: *ReportContext) void {
|
||
self.before_pf.deinit(self.allocator);
|
||
self.after_pf.deinit(self.allocator);
|
||
}
|
||
};
|
||
|
||
const PrepareError = error{PrepareFailed};
|
||
|
||
/// Run the common pipeline - resolve endpoints, load the merged
|
||
/// portfolio at both revisions, fetch prices, build the report.
|
||
///
|
||
/// Shared between `run` (prints the report) and
|
||
/// `computeAttributionImpl` (aggregates totals). Centralizes the git
|
||
/// plumbing and the price-loading step; callers own their output
|
||
/// decisions.
|
||
///
|
||
/// `paths` is the whole `portfolio*.srf` glob, and every file is read
|
||
/// at both revisions and merged - the same union the live loader and
|
||
/// the TUI see. Reading only the first file made a sold lot moved into
|
||
/// a sibling `portfolio_closed.srf` look like a bare disappearance,
|
||
/// and hid the `close_price` that records what the sale actually
|
||
/// realized. `paths[0]` remains the anchor for sibling-file derivation
|
||
/// (`accounts.srf`, `transaction_log.srf`), matching
|
||
/// `LoadedPortfolio`'s documented convention.
|
||
///
|
||
/// Stderr output is gated by `verbosity`: `.verbose` is the `run`
|
||
/// path (user sees why things failed); `.silent` is the attribution
|
||
/// path (failure just means "no attribution line", don't nag).
|
||
fn prepareReport(
|
||
io: std.Io,
|
||
allocator: std.mem.Allocator,
|
||
arena: std.mem.Allocator,
|
||
env: *const std.process.Environ.Map,
|
||
svc: *zfin.DataService,
|
||
paths: []const []const u8,
|
||
before_spec: ?git.CommitSpec,
|
||
after_spec: ?git.CommitSpec,
|
||
as_of: Date,
|
||
color: bool,
|
||
refresh: framework.RefreshPolicy,
|
||
verbosity: Verbosity,
|
||
) PrepareError!ReportContext {
|
||
if (paths.len == 0) {
|
||
if (verbosity == .verbose) cli.stderrPrint(io, "Error: No portfolio file found\n");
|
||
return error.PrepareFailed;
|
||
}
|
||
const portfolio_path = paths[0];
|
||
const repo = git.findRepo(io, arena, env, portfolio_path) catch |err| {
|
||
if (verbosity == .verbose) {
|
||
switch (err) {
|
||
error.NotInGitRepo => cli.stderrPrint(io, "Error: contributions requires portfolio.srf to be in a git repo.\n"),
|
||
error.GitUnavailable => cli.stderrPrint(io, "Error: could not run 'git'. Is git installed and on PATH?\n"),
|
||
else => cli.stderrPrint(io, "Error locating git repo.\n"),
|
||
}
|
||
}
|
||
return error.PrepareFailed;
|
||
};
|
||
|
||
// Dirty/untracked status covers EVERY portfolio file, not just the
|
||
// anchor: editing a sibling is just as much a working-copy change,
|
||
// and treating the tree as clean would diff HEAD~1..HEAD and miss
|
||
// the edit entirely.
|
||
var rel_paths: std.ArrayList([]const u8) = .empty;
|
||
var dirty = false;
|
||
for (paths) |abs| {
|
||
const rel = git.relPathIn(arena, repo, abs) catch return error.PrepareFailed;
|
||
rel_paths.append(arena, rel) catch return error.PrepareFailed;
|
||
const status = git.pathStatus(io, arena, env, repo.root, rel) catch {
|
||
if (verbosity == .verbose) cli.stderrPrint(io, "Error: could not determine git status of portfolio.srf.\n");
|
||
return error.PrepareFailed;
|
||
};
|
||
if (status == .untracked) {
|
||
if (verbosity == .verbose) {
|
||
var buf: [512]u8 = undefined;
|
||
const msg = std.fmt.bufPrint(&buf, "Error: {s} is not tracked in git. Add and commit it first.\n", .{rel}) catch "Error: portfolio file is not tracked in git. Add and commit it first.\n";
|
||
cli.stderrPrint(io, msg);
|
||
}
|
||
return error.PrepareFailed;
|
||
}
|
||
if (status == .modified) dirty = true;
|
||
}
|
||
|
||
const endpoints = resolveEndpoints(io, arena, env, repo, rel_paths.items, before_spec, after_spec, dirty, verbosity) catch return error.PrepareFailed;
|
||
|
||
// Load the merged view at both endpoints: before is always from
|
||
// git; after is either from git (at some revision) or from the
|
||
// working copy. Files absent at a revision are skipped, so a
|
||
// portfolio that gained a sibling file mid-history still diffs.
|
||
var before_pf = portfolio_loader.loadPortfolioFromPathsAtRev(io, allocator, env, paths, endpoints.range.before_rev, as_of) orelse {
|
||
if (verbosity == .verbose) cli.stderrPrint(io, "Error reading before-side portfolio from git.\n");
|
||
return error.PrepareFailed;
|
||
};
|
||
errdefer before_pf.deinit(allocator);
|
||
|
||
var after_pf = if (endpoints.range.after_rev) |rev|
|
||
portfolio_loader.loadPortfolioFromPathsAtRev(io, allocator, env, paths, rev, as_of) orelse {
|
||
if (verbosity == .verbose) cli.stderrPrint(io, "Error reading after-side portfolio from git.\n");
|
||
return error.PrepareFailed;
|
||
}
|
||
else
|
||
portfolio_loader.loadPortfolioFromPaths(io, allocator, paths, as_of) orelse {
|
||
if (verbosity == .verbose) cli.stderrPrint(io, "Error reading working-copy portfolio file.\n");
|
||
return error.PrepareFailed;
|
||
};
|
||
errdefer after_pf.deinit(allocator);
|
||
|
||
// Fetch current prices (cache-hit preferred) for DRIP/share-delta valuation.
|
||
var prices = std.StringHashMap(f64).init(arena);
|
||
var sym_set = std.StringHashMap(void).init(arena);
|
||
for (before_pf.portfolio.lots) |l| {
|
||
if (l.security_type == .stock and !(l.price != null and l.ticker == null)) {
|
||
sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed;
|
||
}
|
||
}
|
||
for (after_pf.portfolio.lots) |l| {
|
||
if (l.security_type == .stock and !(l.price != null and l.ticker == null)) {
|
||
sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed;
|
||
}
|
||
}
|
||
var syms: std.ArrayList([]const u8) = .empty;
|
||
var sit = sym_set.keyIterator();
|
||
while (sit.next()) |k| syms.append(arena, k.*) catch return error.PrepareFailed;
|
||
|
||
if (syms.items.len > 0) {
|
||
var load_result = cli.loadPortfolioPrices(io, svc, syms.items, &.{}, refresh, color);
|
||
defer load_result.deinit();
|
||
var pit = load_result.prices.iterator();
|
||
while (pit.next()) |entry| {
|
||
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch return error.PrepareFailed;
|
||
}
|
||
}
|
||
|
||
// Load accounts.srf (if present) so opt-in cash-delta
|
||
// reclassification fires at diff time. When missing or
|
||
// unparseable, computeReport falls back to the default cash_delta
|
||
// classification - no account gets the opt-in treatment.
|
||
var account_map_opt = svc.loadAccountMap(allocator, portfolio_path);
|
||
defer if (account_map_opt) |*am| am.deinit();
|
||
|
||
// Load transaction_log.srf from BOTH sides of the diff. The
|
||
// matcher considers only records that are NEW in the after-side
|
||
// log (i.e. added to `transaction_log.srf` since `before_rev`).
|
||
// This decouples record matching from the record's
|
||
// `transfer::DATE`, allowing back-dated entries to pair with
|
||
// the diff that introduced them.
|
||
//
|
||
// Path is sibling to portfolio.srf in the same git repo.
|
||
// Missing-on-either-side is OK: file may not have existed in
|
||
// before_rev (treat as empty) or may not exist in working copy
|
||
// (treat as no records -> matcher is a no-op).
|
||
const tlog_rel_path = blk: {
|
||
const dir_end = if (std.mem.lastIndexOfScalar(u8, repo.rel_path, '/')) |idx| idx + 1 else 0;
|
||
break :blk std.fmt.allocPrint(arena, "{s}transaction_log.srf", .{repo.rel_path[0..dir_end]}) catch return error.PrepareFailed;
|
||
};
|
||
|
||
var before_tlog_opt: ?transaction_log.TransactionLog = blk: {
|
||
const data = git.show(io, arena, env, repo.root, endpoints.range.before_rev, tlog_rel_path) catch break :blk null;
|
||
break :blk transaction_log.parseTransactionLogFile(arena, data) catch null;
|
||
};
|
||
defer if (before_tlog_opt) |*tl| tl.deinit();
|
||
|
||
var after_tlog_opt: ?transaction_log.TransactionLog = blk: {
|
||
if (endpoints.range.after_rev) |rev| {
|
||
const data = git.show(io, arena, env, repo.root, rev, tlog_rel_path) catch break :blk null;
|
||
break :blk transaction_log.parseTransactionLogFile(arena, data) catch null;
|
||
} else {
|
||
break :blk svc.loadTransferLog(portfolio_path);
|
||
}
|
||
};
|
||
defer if (after_tlog_opt) |*tl| tl.deinit();
|
||
|
||
// Diff: keep only the records new in after. The slice borrows
|
||
// from after_tlog_opt's record memory; both must outlive the
|
||
// matcher call below (they do - same arena scope).
|
||
const new_records: ?[]const transaction_log.TransferRecord = blk: {
|
||
const after_tl = if (after_tlog_opt) |*tl| tl else break :blk null;
|
||
const before_ptr: ?*const transaction_log.TransactionLog = if (before_tlog_opt) |*tl| tl else null;
|
||
break :blk diffTransferLogs(arena, before_ptr, after_tl) catch return error.PrepareFailed;
|
||
};
|
||
|
||
const report = computeReport(
|
||
arena,
|
||
before_pf.portfolio.lots,
|
||
after_pf.portfolio.lots,
|
||
&prices,
|
||
as_of,
|
||
.{
|
||
.account_map = if (account_map_opt) |*am| am else null,
|
||
.transfer_log = new_records,
|
||
},
|
||
) catch {
|
||
if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n");
|
||
return error.PrepareFailed;
|
||
};
|
||
|
||
return .{
|
||
.endpoints = endpoints,
|
||
.before_pf = before_pf,
|
||
.after_pf = after_pf,
|
||
.allocator = allocator,
|
||
.report = report,
|
||
};
|
||
}
|
||
|
||
/// Whether `resolveEndpoints` / `prepareReport` should print
|
||
/// explanatory stderr messages when the window can't be resolved. The
|
||
/// main `run` command uses `.verbose` so the user sees why the command
|
||
/// failed; the internal `computeAttributionSpec` helper uses `.silent`
|
||
/// because a missing git window is an expected null-return case, not
|
||
/// a hard error.
|
||
const Verbosity = enum { verbose, silent };
|
||
|
||
/// Resolve `since` / `until` flags plus dirty-working-tree state to
|
||
/// the pair of git revisions to diff, along with a human-readable
|
||
/// label for the report header.
|
||
///
|
||
/// Pure SHA-level resolution is delegated to `git.resolveCommitRange`;
|
||
/// this wrapper adds:
|
||
/// - Label formatting (CLI-level presentation concern).
|
||
/// - Stderr messages on failure (respecting `verbosity`).
|
||
/// - A friendly "resolved to the same commit" warning when
|
||
/// `--since` and `--until` collapse.
|
||
fn resolveEndpoints(
|
||
io: std.Io,
|
||
arena: std.mem.Allocator,
|
||
env: *const std.process.Environ.Map,
|
||
repo: git.RepoInfo,
|
||
rel_paths: []const []const u8,
|
||
before: ?git.CommitSpec,
|
||
after: ?git.CommitSpec,
|
||
dirty: bool,
|
||
verbosity: Verbosity,
|
||
) !Endpoints {
|
||
// Snap any requested date to the snapshot that actually exists at-or-before it,
|
||
// BEFORE handing it to git. `compare` has always done this and it is the other
|
||
// half of making the two commands agree: a bare `--since 1W` names a date one
|
||
// week back, which lands on a weekend more often than not, and there is no
|
||
// snapshot for a Saturday. Without snapping, `.snapshot_add` finds no file,
|
||
// degrades to `commitAtOrBeforeDate`, and the whole point is lost - which is
|
||
// exactly what `--since 1W` did while `compare 1W` was already correct.
|
||
const before_snapped = snapSpec(io, arena, repo, rel_paths, before, verbosity, "since");
|
||
const after_snapped = snapSpec(io, arena, repo, rel_paths, after, verbosity, "until");
|
||
|
||
const range = git.resolveCommitRangeSpec(io, arena, env, repo, rel_paths, before_snapped, after_snapped, dirty) catch |err| {
|
||
if (verbosity == .verbose) {
|
||
switch (err) {
|
||
error.NoCommitAtOrBefore => {
|
||
// Report which spec triggered it. When both are
|
||
// date specs we can't easily tell from here;
|
||
// covers both by naming the before-side.
|
||
var before_buf: [10]u8 = undefined;
|
||
const before_str = specDisplayString(before, &before_buf);
|
||
var msg_buf: [256]u8 = undefined;
|
||
const msg = std.fmt.bufPrint(&msg_buf, "Error: no commit of {s} at or before {s}.\n", .{ repo.rel_path, before_str }) catch "Error: no commit at or before requested date.\n";
|
||
cli.stderrPrint(io, msg);
|
||
},
|
||
error.InvalidArg => {
|
||
cli.stderrPrint(io, "Error: --commit-before cannot be `working`: diffing the working copy against itself is meaningless.\n");
|
||
},
|
||
else => {
|
||
cli.stderrPrint(io, "Error resolving commit range: ");
|
||
cli.stderrPrint(io, @errorName(err));
|
||
cli.stderrPrint(io, "\n");
|
||
},
|
||
}
|
||
}
|
||
return error.ResolveFailed;
|
||
};
|
||
|
||
// Label the endpoints based on the resolution mode. Matches the
|
||
// legacy phrasing where possible so existing test assertions still
|
||
// pass.
|
||
const label = try buildLabelFromSpecs(arena, range, before_snapped, after_snapped, dirty);
|
||
|
||
// Same-commit warning for the two-date window case. Legit confusion
|
||
// trigger - the user asked for a diff between two dates that both
|
||
// snap to the same commit (e.g., no activity in the window).
|
||
if (before_snapped != null and after_snapped != null and verbosity == .verbose) {
|
||
if (range.after_rev) |after_rev| {
|
||
if (std.mem.eql(u8, range.before_rev, after_rev)) {
|
||
cli.stderrPrint(io, "Warning: before and after resolve to the same commit; no changes to report.\n");
|
||
}
|
||
}
|
||
}
|
||
|
||
// Snap-note warning: when the user provided a date-form spec and
|
||
// the resolved commit's committer-date is >1 day before the
|
||
// requested date, emit a muted hint so the user can verify the
|
||
// selected commit matches their intent. See
|
||
// `docs/notes/commit-window-edge-case.md` (aka TODO.md) for the
|
||
// motivating scenario.
|
||
if (verbosity == .verbose) {
|
||
try maybeSnapNote(io, arena, env, repo, before_snapped, range.before_rev, "before", range.snapshot_anchor);
|
||
if (range.after_rev) |ar| {
|
||
try maybeSnapNote(io, arena, env, repo, after_snapped, ar, "after", range.snapshot_anchor_after);
|
||
}
|
||
}
|
||
|
||
// Deliberately NOT gated on `verbosity`. `.silent` exists because
|
||
// `computeAttributionSpec` calls this speculatively and an unresolvable window
|
||
// is an ordinary null return - but that is the `compare` path, and `compare`'s
|
||
// headline is exactly what a straddled anchor corrupts. This note only fires
|
||
// when a snapshot WAS resolved and disagreed with the copy on disk, so it can
|
||
// never fire on the case `.silent` was introduced for.
|
||
maybeSnapshotAnchorNote(io, range.snapshot_anchor);
|
||
maybeSnapshotAnchorNote(io, range.snapshot_anchor_after);
|
||
|
||
return .{ .range = range, .label = label };
|
||
}
|
||
|
||
/// Replace a `.snapshot_add` date with the nearest snapshot at-or-before it.
|
||
///
|
||
/// Returns the spec untouched for every other variant, and untouched when no
|
||
/// snapshot resolves at all - the degraded path is `git.resolveSpec`'s to handle,
|
||
/// and `maybeSnapNote` reports it from there.
|
||
///
|
||
/// Announces a snap the way `compare` does, because silently moving the date a
|
||
/// user typed is how two commands come to disagree about the same week while both
|
||
/// look right.
|
||
fn snapSpec(
|
||
io: std.Io,
|
||
arena: std.mem.Allocator,
|
||
repo: git.RepoInfo,
|
||
rel_paths: []const []const u8,
|
||
spec: ?git.CommitSpec,
|
||
verbosity: Verbosity,
|
||
flag: []const u8,
|
||
) ?git.CommitSpec {
|
||
const s = spec orelse return spec;
|
||
const requested = switch (s) {
|
||
.snapshot_add => |d| d,
|
||
else => return spec,
|
||
};
|
||
|
||
const first = if (rel_paths.len > 0) rel_paths[0] else return spec;
|
||
const pf_path = std.fs.path.join(arena, &.{ repo.root, first }) catch return spec;
|
||
const hist_dir = history.deriveHistoryDir(arena, pf_path) catch return spec;
|
||
const resolved = history.resolveSnapshotDate(io, arena, hist_dir, requested) catch return spec;
|
||
if (resolved.exact) return spec;
|
||
|
||
if (verbosity == .verbose) {
|
||
var buf: [200]u8 = undefined;
|
||
const msg = std.fmt.bufPrint(&buf, "(--{s} {f} has no snapshot; using {f}, the nearest at-or-before)\n", .{ flag, requested, resolved.actual }) catch "";
|
||
if (msg.len > 0) cli.stderrPrint(io, msg);
|
||
}
|
||
return .{ .snapshot_add = resolved.actual };
|
||
}
|
||
|
||
/// Say something when the snapshot anchor was not clean.
|
||
///
|
||
/// Both cases mean the same thing operationally: the value side of a comparison
|
||
/// reads `history/<date>-portfolio.srf` off disk while the attribution side reads
|
||
/// `portfolio.srf` at a commit, and those two can describe different portfolios.
|
||
/// When they do, `gains = delta - contributions` mixes windows and the error is
|
||
/// invisible in the output - it looks like a plausible number. The motivating case
|
||
/// had a review's contributions and a five-figure outflow counted in two
|
||
/// consecutive reports because the anchor predated the reconcile.
|
||
///
|
||
/// `corrected_from` is a note rather than a warning: the anchor moved and the
|
||
/// answer is now right, but the operator should know their history has a commit
|
||
/// where the snapshot and the portfolio disagreed, because that is a staging
|
||
/// slip that will recur.
|
||
fn maybeSnapshotAnchorNote(io: std.Io, anchor: ?git.SnapshotAnchor) void {
|
||
const a = anchor orelse return;
|
||
var buf: [460]u8 = undefined;
|
||
if (a.corrected_from) |from| {
|
||
const msg = std.fmt.bufPrint(&buf, "Note: the {f} snapshot was regenerated after it was first committed; anchoring the {s} side on {s} rather than {s}, which described different positions.\n", .{ a.date, a.side.label(), shortSha(a.commit), shortSha(from) }) catch return;
|
||
cli.stderrPrint(io, msg);
|
||
return;
|
||
}
|
||
if (a.unmatched) {
|
||
const msg = std.fmt.bufPrint(&buf, "Warning: no commit of the {f} snapshot matches the copy on disk, so the {s} side's values and attribution describe different portfolios. Contributions and gains for this window are NOT reliable - re-run `zfin snapshot --force` for that date and commit it alongside portfolio.srf.\n", .{ a.date, a.side.label() }) catch return;
|
||
cli.stderrPrint(io, msg);
|
||
}
|
||
}
|
||
|
||
/// Render a `CommitSpec` for user-facing error messages. Dates and
|
||
/// working-copy sentinels get formatted; refs are passed through.
|
||
/// When `spec` is null, returns "(unset)".
|
||
fn specDisplayString(spec: ?git.CommitSpec, date_buf: *[10]u8) []const u8 {
|
||
const s = spec orelse return "(unset)";
|
||
return switch (s) {
|
||
.git_ref => |r| r,
|
||
.date_at_or_before, .snapshot_add => |d| std.fmt.bufPrint(date_buf, "{f}", .{d}) catch "????-??-??",
|
||
.working_copy => "working",
|
||
};
|
||
}
|
||
|
||
/// If the user's before spec was a date form and the resolved commit
|
||
/// sits more than 1 day earlier than the requested date, print a
|
||
/// muted hint. Catches the "I committed after my review date" case
|
||
/// where `--since 1W` picks up a commit 7+ days before the cutoff.
|
||
fn maybeSnapNote(
|
||
io: std.Io,
|
||
arena: std.mem.Allocator,
|
||
env: *const std.process.Environ.Map,
|
||
repo: git.RepoInfo,
|
||
spec: ?git.CommitSpec,
|
||
resolved_ref: []const u8,
|
||
label: []const u8,
|
||
/// The resolved anchor for this side, when the spec was `.snapshot_add`. Null
|
||
/// means no snapshot for that date was committed and resolution FELL BACK to
|
||
/// `commitAtOrBeforeDate` - which is precisely the case this note is for.
|
||
anchor: ?git.SnapshotAnchor,
|
||
) !void {
|
||
const s = spec orelse return;
|
||
const requested_date = specDate(s) orelse return;
|
||
|
||
// A snapshot-anchored side that actually found its snapshot is not drifting -
|
||
// it is pinned to the commit that recorded that date, which is the point. Only
|
||
// the degraded path, where no snapshot existed and it fell back to a date, can
|
||
// land arbitrarily far from what was asked for.
|
||
//
|
||
// Written as "did the anchor resolve" rather than "is the spec a date", because
|
||
// the previous form (`.date_at_or_before => |d| d, else => return`) silenced
|
||
// this note outright the moment `--since` began producing `.snapshot_add`.
|
||
if (s == .snapshot_add and anchor != null) return;
|
||
|
||
// Get the committer-date of the resolved commit. `%ct` gives a
|
||
// Unix timestamp.
|
||
const ts = git.commitTimestamp(io, arena, env, repo.root, resolved_ref) catch return;
|
||
const commit_date = zfin.Date.fromEpoch(ts);
|
||
if (!commit_date.lessThan(requested_date)) return;
|
||
|
||
const gap_days = requested_date.days - commit_date.days;
|
||
if (gap_days <= 1) return;
|
||
|
||
var msg_buf: [320]u8 = undefined;
|
||
const msg = std.fmt.bufPrint(
|
||
&msg_buf,
|
||
"(git {s} uses commit {s} from {f}, {d} day{s} before requested {f}; no " ++
|
||
"snapshot for that date is committed, so there was nothing to anchor on - " ++
|
||
"use --commit-{s} to pin a revision explicitly)\n",
|
||
.{
|
||
label,
|
||
shortSha(resolved_ref),
|
||
commit_date,
|
||
gap_days,
|
||
if (gap_days == 1) "" else "s",
|
||
requested_date,
|
||
label,
|
||
},
|
||
) catch return;
|
||
cli.stderrPrint(io, msg);
|
||
}
|
||
|
||
/// Abbreviate a commit ref for display. SHAs get shortened to 7
|
||
/// chars; symbolic refs (HEAD, HEAD~1) stay as-is.
|
||
fn shortSha(ref: []const u8) []const u8 {
|
||
if (std.mem.startsWith(u8, ref, "HEAD")) return ref;
|
||
if (ref.len > 7) return ref[0..7];
|
||
return ref;
|
||
}
|
||
|
||
/// Build a report-header label from the resolved range + the
|
||
/// user-provided specs. Date specs render their requested form;
|
||
/// refs render verbatim; null falls back to the legacy HEAD~/HEAD
|
||
/// naming.
|
||
fn buildLabelFromSpecs(
|
||
arena: std.mem.Allocator,
|
||
range: git.CommitRange,
|
||
before: ?git.CommitSpec,
|
||
after: ?git.CommitSpec,
|
||
dirty: bool,
|
||
) ![]const u8 {
|
||
// Preserve the original date-based label when both specs are
|
||
// date-form (most common user-facing flow). Fall back to a
|
||
// spec-agnostic rendering otherwise.
|
||
const before_date: ?Date = if (before) |b| switch (b) {
|
||
.date_at_or_before => |d| d,
|
||
else => null,
|
||
} else null;
|
||
const after_date: ?Date = if (after) |a| switch (a) {
|
||
.date_at_or_before => |d| d,
|
||
else => null,
|
||
} else null;
|
||
|
||
// If either spec is a non-date form, emit a label describing
|
||
// the resolved range. Otherwise use the legacy date/label path
|
||
// for back-compat with snapshot tests.
|
||
const has_non_date = (before != null and before_date == null) or
|
||
(after != null and after_date == null);
|
||
if (has_non_date) {
|
||
const before_str = try specLabel(arena, before, range.before_rev);
|
||
const after_str = try specLabelAfter(arena, after, range.after_rev);
|
||
return std.fmt.allocPrint(arena, "{s} vs {s}", .{ before_str, after_str });
|
||
}
|
||
|
||
return buildLabel(arena, range, before_date, after_date, dirty);
|
||
}
|
||
|
||
/// The date a spec carries, for the two variants that carry one.
|
||
///
|
||
/// Exists so callers stop pattern-matching a single variant. Both `--since 1W` and
|
||
/// `compare`'s snapshot anchoring are "a date the user named"; a check written
|
||
/// against `.date_at_or_before` alone goes quietly blind when that mapping changes.
|
||
fn specDate(spec: git.CommitSpec) ?zfin.Date {
|
||
return switch (spec) {
|
||
.date_at_or_before, .snapshot_add => |d| d,
|
||
.git_ref, .working_copy => null,
|
||
};
|
||
}
|
||
|
||
fn specLabel(arena: std.mem.Allocator, spec: ?git.CommitSpec, resolved_ref: []const u8) ![]const u8 {
|
||
const s = spec orelse return arena.dupe(u8, resolved_ref);
|
||
return switch (s) {
|
||
.git_ref => |r| arena.dupe(u8, r),
|
||
.date_at_or_before => |d| std.fmt.allocPrint(arena, "commit at-or-before {f}", .{d}),
|
||
// Names the anchor, because "the commit that recorded this snapshot" is a
|
||
// materially different claim from "some commit near this date" and the
|
||
// header is where a reader checks which window they got.
|
||
.snapshot_add => |d| std.fmt.allocPrint(arena, "the commit recording {f}", .{d}),
|
||
.working_copy => arena.dupe(u8, "working copy"),
|
||
};
|
||
}
|
||
|
||
fn specLabelAfter(arena: std.mem.Allocator, spec: ?git.CommitSpec, resolved_ref: ?[]const u8) ![]const u8 {
|
||
if (spec) |s| return specLabel(arena, s, resolved_ref orelse "working");
|
||
if (resolved_ref) |r| return arena.dupe(u8, r);
|
||
return arena.dupe(u8, "working copy");
|
||
}
|
||
|
||
/// Build the human-readable header label for a resolved range.
|
||
fn buildLabel(
|
||
arena: std.mem.Allocator,
|
||
range: git.CommitRange,
|
||
since: ?Date,
|
||
until: ?Date,
|
||
dirty: bool,
|
||
) ![]const u8 {
|
||
// No date window -> legacy labels, matches pre-since/--until wording.
|
||
if (since == null) {
|
||
return if (dirty)
|
||
"Comparing working copy against HEAD"
|
||
else
|
||
"Working tree clean - comparing HEAD~1 against HEAD";
|
||
}
|
||
|
||
var since_buf: [10]u8 = undefined;
|
||
const since_str = std.fmt.bufPrint(&since_buf, "{f}", .{since.?}) catch "????-??-??";
|
||
|
||
if (until) |until_date| {
|
||
var until_buf: [10]u8 = undefined;
|
||
const until_str = std.fmt.bufPrint(&until_buf, "{f}", .{until_date}) catch "????-??-??";
|
||
return std.fmt.allocPrint(arena, "Comparing {s} ({s}) against {s} ({s})", .{
|
||
short(range.before_rev),
|
||
since_str,
|
||
short(range.after_rev.?),
|
||
until_str,
|
||
});
|
||
}
|
||
|
||
// --since only: after side is HEAD or working copy.
|
||
return if (dirty)
|
||
std.fmt.allocPrint(arena, "Comparing {s} ({s}) against working copy", .{ short(range.before_rev), since_str })
|
||
else
|
||
std.fmt.allocPrint(arena, "Comparing {s} ({s}) against HEAD", .{ short(range.before_rev), since_str });
|
||
}
|
||
|
||
/// 7-char short SHA for display. Runtime behavior is already
|
||
/// length-agnostic (accepts any `>= 7`), so this works for both SHA-1
|
||
/// (40-char) and SHA-256 (64-char) repos without modification.
|
||
/// Slices rather than reallocs.
|
||
fn short(sha: []const u8) []const u8 {
|
||
return if (sha.len >= 7) sha[0..7] else sha;
|
||
}
|
||
|
||
// ── Attribution helper for compare ──────────────────────────
|
||
|
||
/// Aggregated "money in" totals over a date window, produced by the
|
||
/// contributions pipeline but distilled to the numbers needed for
|
||
/// the compare-command attribution line.
|
||
///
|
||
/// "Contributions" in the plain-English sense (what the user wrote a
|
||
/// check for or what got DRIP'd back in) = `new_contributions` +
|
||
/// `drip`. CD face-value rollovers are *not* here - moving a maturing
|
||
/// CD's face value back into cash isn't new money, and `new_cash`
|
||
/// records during that transaction don't double-count because the
|
||
/// pipeline separates cd_matured from cash_delta.
|
||
pub const AttributionSummary = struct {
|
||
/// Fresh lots that represent outside money: 401k contributions,
|
||
/// DRIP-false stock purchases, CD openings, option opens, cash
|
||
/// top-ups. Matches the "New contributions / purchases" section
|
||
/// in the full report.
|
||
new_contributions: f64,
|
||
/// Dividend reinvestments: new `drip::true` lots + share increases
|
||
/// on same-key drip lots + rollup share deltas (ambiguous
|
||
/// contribution-vs-DRIP cases, treated as DRIP here to avoid
|
||
/// double-counting with cash contributions).
|
||
drip: f64,
|
||
/// Gross value of movements this pipeline deliberately does NOT count, split by
|
||
/// direction. Reported, never added to `total()`.
|
||
///
|
||
/// ## Why these are surfaced at all
|
||
///
|
||
/// `compare` derives market performance as a RESIDUAL: `gains = delta -
|
||
/// contributions`. That makes every classification gap indistinguishable from
|
||
/// market movement. Raw cash-balance changes are noise by default (interest,
|
||
/// DRIP legs, settlement sweeps), and share reductions are treated as a funding
|
||
/// source rather than an outflow - both defensible, and both invisible once
|
||
/// folded into a residual.
|
||
///
|
||
/// On one real week that hid a $25,079.42 cash increase against a $25,111.24
|
||
/// share reduction. They were two legs of one internal move and netted to
|
||
/// -$31.82, so `gains` was very nearly right - but nothing on screen said so,
|
||
/// and an unmatched leg of that size would have been reported as market
|
||
/// performance without comment.
|
||
///
|
||
/// ## Why gross rather than net
|
||
///
|
||
/// A single net figure would have read "-$31.82" for that week: accurate, and
|
||
/// useless. It conceals that $25k moved. Two gross figures say "$25k came in,
|
||
/// $25k went out, they cancel", which is the fact worth knowing.
|
||
uncounted_in: f64 = 0,
|
||
/// Gross uncounted outflows, as a NEGATIVE number so the sign carries meaning
|
||
/// at the callsite without a naming convention to remember.
|
||
uncounted_out: f64 = 0,
|
||
|
||
pub fn total(self: AttributionSummary) f64 {
|
||
return self.new_contributions + self.drip;
|
||
}
|
||
|
||
/// True when there is any uncounted movement worth showing. A cent threshold,
|
||
/// because float noise on a quiet week should not produce a line.
|
||
pub fn hasUncounted(self: AttributionSummary) bool {
|
||
return @abs(self.uncounted_in) >= 0.005 or @abs(self.uncounted_out) >= 0.005;
|
||
}
|
||
};
|
||
|
||
/// Run the contributions pipeline over a commit window and return the
|
||
/// aggregated "money in" totals. Returns null on any failure -
|
||
/// intended callers (e.g. `compare`) surface the attribution line
|
||
/// opportunistically; a missing git repo or no resolvable commits
|
||
/// shouldn't break the primary command.
|
||
///
|
||
/// Parameters mirror `run` but without the writer/color: no output
|
||
/// is produced. Failures swallow silently via the shared
|
||
/// `prepareReport` helper's `.silent` verbosity.
|
||
///
|
||
/// Classification logic is SOLELY in `computeReport` - this function
|
||
/// just buckets pre-classified change kinds into their totals. In
|
||
/// particular, opt-in cash-delta handling is resolved at diff time
|
||
/// (cash_delta -> cash_contribution on accounts marked
|
||
/// `cash_is_contribution::true`), so the attribution line here and
|
||
/// the grand total in the full `zfin contributions` report come out
|
||
/// of the same classifier and always agree over the same window.
|
||
pub fn computeAttributionSpec(
|
||
io: std.Io,
|
||
allocator: std.mem.Allocator,
|
||
env: *const std.process.Environ.Map,
|
||
svc: *zfin.DataService,
|
||
paths: []const []const u8,
|
||
before: ?git.CommitSpec,
|
||
after: ?git.CommitSpec,
|
||
as_of: Date,
|
||
color: bool,
|
||
refresh: framework.RefreshPolicy,
|
||
) ?AttributionSummary {
|
||
if (before == null and after != null) return null;
|
||
|
||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .silent) catch return null;
|
||
defer ctx.deinit();
|
||
|
||
return summarizeAttribution(ctx);
|
||
}
|
||
|
||
// ── Public audit hook ────────────────────────────────────────
|
||
|
||
/// Default dollar threshold above which a new lot (new_stock /
|
||
/// new_drip_lot / new_cash / new_cd / cash_contribution) gets flagged
|
||
/// in the audit "Large new lots - confirm source" section. Below this
|
||
/// threshold new lots pass silently - the goal is to catch unconfirmed
|
||
/// six-figure movements, not flag every payroll contribution.
|
||
///
|
||
/// $10k is a judgment call: high enough to ignore routine payroll ESPP
|
||
/// accruals and $1-$2k weekly deposits, low enough to surface a typical
|
||
/// IRA contribution or a genuine transfer. This is only the fallback -
|
||
/// an account can override it per-account with an
|
||
/// `audit_large_lot_threshold:num:<DOLLARS>` field on its accounts.srf
|
||
/// record (see `AccountTaxEntry.audit_large_lot_threshold` /
|
||
/// `AccountMap.largeLotThresholdFor`), so a noisy ESPP account can
|
||
/// raise its bar without going blind on a quiet brokerage account.
|
||
const default_audit_large_lot_threshold: f64 = 10_000.0;
|
||
|
||
/// Descriptor of a "large new lot" the audit command may want to
|
||
/// surface. Emitted by `findUnmatchedLargeLots` for any new-side
|
||
/// Change whose `value()` meets the resolved threshold and which
|
||
/// was NOT reclassified by the transfer-log matcher. All string
|
||
/// fields are caller-arena-owned through the `UnmatchedLargeLotSet`
|
||
/// wrapper; the caller frees everything at once via `deinit`.
|
||
pub const UnmatchedLargeLot = struct {
|
||
/// The account the lot landed on (arena-owned copy).
|
||
account: []const u8,
|
||
/// Security ticker, or empty string for cash-kind lots.
|
||
symbol: []const u8,
|
||
/// `.stock`, `.cash`, `.cd`, etc. Drives the dest_lot shape in
|
||
/// the suggested template.
|
||
security_type: LotType,
|
||
/// Dollar value of the lot (`Change.value()`).
|
||
value: f64,
|
||
/// Lot open_date. Used in the `dest_lot::SYMBOL@OPEN_DATE`
|
||
/// template for stock / CD destinations.
|
||
open_date: Date,
|
||
};
|
||
|
||
/// Result wrapper that owns both the slice and the arena the string
|
||
/// fields live in. Caller must `deinit`.
|
||
pub const UnmatchedLargeLotSet = struct {
|
||
lots: []UnmatchedLargeLot,
|
||
arena: std.heap.ArenaAllocator,
|
||
|
||
pub fn deinit(self: *UnmatchedLargeLotSet) void {
|
||
self.arena.deinit();
|
||
}
|
||
};
|
||
|
||
/// Find new-side lots (new_stock / new_drip_lot / new_cash / new_cd
|
||
/// / cash_contribution) whose unattributed value meets the audit
|
||
/// large-lot threshold but weren't matched to a record in
|
||
/// `transaction_log.srf` over the HEAD -> working-copy window.
|
||
/// Mirrors the `zfin contributions` zero-flag path - uses
|
||
/// `prepareReport`'s shared git + portfolio + transfer plumbing so
|
||
/// the classification is identical. Returns null if the pipeline
|
||
/// can't resolve a window (not in a git repo, etc.) - the expected
|
||
/// reason for audit to skip the section, logged at debug level. Any
|
||
/// other failure is returned, never folded into that null: doing so
|
||
/// once made the whole section disappear over one undated lot.
|
||
///
|
||
/// The threshold is resolved per lot: an account's
|
||
/// `audit_large_lot_threshold` (from `account_map`) wins, otherwise
|
||
/// `default_audit_large_lot_threshold` applies. Pass `account_map =
|
||
/// null` to use the default for everything.
|
||
///
|
||
/// Consumed by `zfin audit` to prompt the user to either confirm
|
||
/// the lot as an external contribution or add a transfer record
|
||
/// when it was really an internal movement. Works whether or not
|
||
/// `transaction_log.srf` exists - when absent, every large lot
|
||
/// surfaces since nothing gets reclassified.
|
||
pub fn findUnmatchedLargeLots(
|
||
io: std.Io,
|
||
allocator: std.mem.Allocator,
|
||
env: *const std.process.Environ.Map,
|
||
svc: *zfin.DataService,
|
||
paths: []const []const u8,
|
||
account_map: ?*const analysis.AccountMap,
|
||
as_of: Date,
|
||
color: bool,
|
||
refresh: framework.RefreshPolicy,
|
||
) !?UnmatchedLargeLotSet {
|
||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||
errdefer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
// Explicit null/null selects the legacy zero-flag path:
|
||
// before=HEAD~1..HEAD if clean, HEAD..working-copy if dirty.
|
||
// Audit cares about the dirty-working-copy case in practice
|
||
// (that's where "unconfirmed large lots" live), but both
|
||
// branches are valid consumers.
|
||
//
|
||
// Separate allocator here so we can tear the whole thing down
|
||
// via `arena_state.deinit` once we've copied out the descriptors.
|
||
var ctx = prepareReport(io, allocator, arena, env, svc, paths, null, null, as_of, color, refresh, .silent) catch |err| {
|
||
std.log.scoped(.contributions).debug("large-lot check skipped: no comparable window ({t})", .{err});
|
||
arena_state.deinit();
|
||
return null;
|
||
};
|
||
defer ctx.deinit();
|
||
|
||
const lots = try collectUnmatchedLargeLots(arena, ctx.report.changes, account_map);
|
||
return .{ .lots = lots, .arena = arena_state };
|
||
}
|
||
|
||
/// Pure filter: pick out new-side Changes whose unattributed value
|
||
/// (`attributedValue()`) is at or above `threshold`, and dupe their
|
||
/// string fields into `arena`. Split out so tests can feed a
|
||
/// synthetic `[]Change` without running the git/IO pipeline.
|
||
///
|
||
/// Cash-destination transfers don't flip their original `new_cash` /
|
||
/// `cash_contribution` Change's kind (a single cash delta can be
|
||
/// drained by multiple records, which the kind field can't
|
||
/// represent). Instead the matcher accumulates the per-record
|
||
/// attribution onto each consumed Change's `transfer_attributed`
|
||
/// field, in iteration order. `attributedValue()` then returns the
|
||
/// residual: 0 for fully-attributed lots, the unattributed
|
||
/// remainder for partials.
|
||
///
|
||
/// Deliberately excludes `partial_transfer_in`. A partial lot already
|
||
/// has an explicit transfer record acknowledging the large movement;
|
||
/// the unmatched residual is typically small (pre-existing cash that
|
||
/// topped the lot off) and surfacing it again would nag on something
|
||
/// the user has already documented. If a residual is large enough to
|
||
/// care about independently, the user can review the lot's full value
|
||
/// via `zfin contributions` - this filter's job is to catch
|
||
/// *unrecorded* large movements, not to re-flag partial ones.
|
||
fn collectUnmatchedLargeLots(
|
||
arena: std.mem.Allocator,
|
||
changes: []const Change,
|
||
account_map: ?*const analysis.AccountMap,
|
||
) ![]UnmatchedLargeLot {
|
||
var out: std.ArrayList(UnmatchedLargeLot) = .empty;
|
||
for (changes) |c| {
|
||
const is_new_side = switch (c.kind) {
|
||
.new_stock, .new_drip_lot, .new_cash, .new_cd, .cash_contribution => true,
|
||
else => false,
|
||
};
|
||
if (!is_new_side) continue;
|
||
|
||
// Per-account threshold: the lot's account can raise/lower its
|
||
// own cutoff (e.g. a noisy ESPP account); otherwise the
|
||
// built-in default applies.
|
||
const threshold = if (account_map) |am|
|
||
(am.largeLotThresholdFor(c.account) orelse default_audit_large_lot_threshold)
|
||
else
|
||
default_audit_large_lot_threshold;
|
||
|
||
// Use attributedValue() so a fully-attributed cash lot
|
||
// (whose `transfer_attributed` covers the whole `value()`)
|
||
// drops out, and a partially-attributed cash lot surfaces
|
||
// only on the unattributed remainder.
|
||
const residual = c.attributedValue();
|
||
if (residual < threshold) continue;
|
||
|
||
// Pass 1's new-lot branch sets open_date. A `cash_contribution`
|
||
// never has one: it is a balance INCREASE on an existing cash
|
||
// lot, and that lot's open_date is when the account was set
|
||
// up, not when this money arrived. `Date.epoch` is the
|
||
// codebase's "no date" (import writes it; `Lot.fromParsed`
|
||
// fills it), and `printLargeLotWarning` renders it as "date
|
||
// unknown" with a `<DATE>` placeholder rather than a real date.
|
||
//
|
||
// This used to `return error.MissingOpenDate`, which the caller
|
||
// collapsed into "no section" - one large deposit to a
|
||
// `cash_is_contribution` account blanked the whole list.
|
||
const od = c.open_date orelse Date.epoch;
|
||
const account_copy = try arena.dupe(u8, c.account);
|
||
const symbol_copy = try arena.dupe(u8, c.symbol);
|
||
try out.append(arena, .{
|
||
.account = account_copy,
|
||
.symbol = symbol_copy,
|
||
.security_type = c.security_type,
|
||
.value = residual,
|
||
.open_date = od,
|
||
});
|
||
}
|
||
return out.toOwnedSlice(arena);
|
||
}
|
||
|
||
/// The kinds that carry real value but are deliberately left OUT of attribution -
|
||
/// and the only place that list lives.
|
||
///
|
||
/// Two consumers read it and they must never disagree: `compare`'s
|
||
/// "Uncounted in/out" headline and this report's own "Uncounted flows" total.
|
||
/// They would drift if each carried its own list, because these six kinds are
|
||
/// scattered across FOUR different print sections below (Cash deltas, Internal
|
||
/// purchases, Flagged for review, Lot edits) and nothing about the layout hints
|
||
/// that they belong to one bucket. That scattering is exactly why the headline
|
||
/// number was untraceable: every row was on screen and no total was.
|
||
///
|
||
/// `cash_delta` is the raw-balance-change bucket (an opted-in account's positive
|
||
/// delta is reclassified to `cash_contribution` at diff time and counted as a
|
||
/// contribution, so it cannot be double-counted here). The share-reduction kinds
|
||
/// are the other side of the same coin: `attributedValue()` pins them to 0 on the
|
||
/// grounds that they are a funding source rather than an outflow, which is right
|
||
/// for attribution and still worth SEEING.
|
||
///
|
||
/// Deliberately NOT here:
|
||
/// - `cd_matured` / `cd_removed_early` have their own section and always pair
|
||
/// with a cash increase in the same account, so counting both legs would
|
||
/// double-report one internal move.
|
||
/// - `transfer_in` / `transfer_out` / `unmatched_transfer` are DECLARED internal
|
||
/// moves (`transaction_log.srf`). The point of the total is UNdeclared
|
||
/// movement; a declared transfer is not a surprise.
|
||
/// - `price_only` carries no share change, so its value is zero anyway.
|
||
fn isUncountedKind(k: ChangeKind) bool {
|
||
return switch (k) {
|
||
.cash_delta, .drip_negative, .lot_removed, .position_closed, .lot_edited, .flagged => true,
|
||
else => false,
|
||
};
|
||
}
|
||
|
||
/// Gross in/out split of the uncounted flows, signed the way `compare` prints
|
||
/// them: `out` accumulates negatives, so `in + out` is the net and needs no
|
||
/// further sign juggling at either call site.
|
||
///
|
||
/// Uses `value()`, not `attributedValue()` - the whole point is the value that
|
||
/// attribution threw away. Note this is a DIFFERENT basis from the figure
|
||
/// `printCollapsedSales` shows for a sale, which uses `face_value` (what the
|
||
/// trade realized) and can legitimately differ from a current-price mark. That
|
||
/// is why only the Cash deltas section gets a per-section total below: there the
|
||
/// two bases coincide, so the section total and this one cannot disagree.
|
||
fn uncountedTotals(changes: []const Change) struct { in: f64, out: f64 } {
|
||
var in: f64 = 0;
|
||
var out: f64 = 0;
|
||
for (changes) |c| {
|
||
if (!isUncountedKind(c.kind)) continue;
|
||
const v = c.value();
|
||
if (v >= 0) in += v else out += v;
|
||
}
|
||
return .{ .in = in, .out = out };
|
||
}
|
||
|
||
fn summarizeAttribution(ctx: ReportContext) AttributionSummary {
|
||
|
||
// Aggregate. Classification logic matches the full-report sections:
|
||
// - New contributions: new_stock + new_cash + new_cd + new_option
|
||
// + cash_contribution (opt-in cash_delta)
|
||
// + partial_transfer_in residual
|
||
// (`value()` - `transfer_attributed`)
|
||
// - DRIP: new_drip_lot + drip_confirmed + rollup_delta
|
||
// `rollup_delta` is the ambiguous "share increased on a drip::false
|
||
// lot" case. Lumping it with DRIP here matches the report's own
|
||
// visual grouping (both shown as positive, both under DRIP-ish
|
||
// headings) and prevents double-counting against cash_delta.
|
||
// `cash_delta` (non-opt-in) is excluded - it's noisy cash movement
|
||
// (DRIP legs, interest, CD coupons, settlement sweeps) that the
|
||
// user hasn't explicitly flagged as a contribution source.
|
||
// `lot_edited` is excluded - it's a reconciliation noise bucket.
|
||
// `transfer_in` / `transfer_out` / `unmatched_transfer` -> $0.
|
||
// `partial_transfer_in` -> residual only (attributedValue()).
|
||
var new_contributions: f64 = 0;
|
||
var drip: f64 = 0;
|
||
for (ctx.report.changes) |c| switch (c.kind) {
|
||
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(),
|
||
.new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
|
||
.partial_transfer_in => new_contributions += c.attributedValue(),
|
||
// The uncounted kinds fall through to `uncountedTotals` below, which owns
|
||
// that list. Everything else contributes nothing.
|
||
else => {},
|
||
};
|
||
const unc = uncountedTotals(ctx.report.changes);
|
||
// Cash-dest transfer attribution is already removed by `attributedValue()` on
|
||
// the per-Change side: `matchCashDestination` accumulates into
|
||
// `transfer_attributed`, so a fully-attributed cash Change contributes zero
|
||
// here without any separate per-account subtraction.
|
||
|
||
return .{
|
||
.new_contributions = new_contributions,
|
||
.drip = drip,
|
||
.uncounted_in = unc.in,
|
||
.uncounted_out = unc.out,
|
||
};
|
||
}
|
||
|
||
// ── Git discovery / invocation ───────────────────────────────
|
||
//
|
||
// Git plumbing lives in `src/git.zig` (shared with future snapshot
|
||
// features). This module only classifies which revisions to diff and
|
||
// how to interpret the result.
|
||
|
||
// ── Diff algorithm ───────────────────────────────────────────
|
||
|
||
/// Categorized change on a single lot-key.
|
||
const ChangeKind = enum {
|
||
new_stock, // lot appeared: stock purchase (drip::false)
|
||
new_drip_lot, // lot appeared: stock with drip::true (confirmed DRIP reinvestment)
|
||
new_cash, // lot appeared: cash added
|
||
new_cd, // lot appeared: CD opened
|
||
new_option, // lot appeared: option opened
|
||
drip_confirmed, // same key on a drip::true stock lot, Δshares > 0
|
||
rollup_delta, // same key on a drip::false stock lot, Δshares > 0 (DRIP or contribution; can't distinguish)
|
||
drip_negative, // same key, stock, Δshares < 0 (share sale on the same lot - unusual)
|
||
cash_delta, // same key, cash, Δshares (treated as noise - interest, DRIP legs)
|
||
/// Positive cash_delta on an account marked
|
||
/// `cash_is_contribution::true` in accounts.srf. Reclassified at
|
||
/// diff time (inside `computeReport`) so every downstream
|
||
/// consumer - the full report, per-account summary, attribution
|
||
/// totals in `compare` - sees the same classification. Without
|
||
/// this single-point-of-truth, compare's attribution line and the
|
||
/// contributions report's grand total could disagree on the same
|
||
/// window.
|
||
cash_contribution,
|
||
cd_matured, // lot disappeared: CD with maturity_date <= today
|
||
cd_removed_early, // lot disappeared: CD with maturity_date > today
|
||
lot_removed, // lot disappeared: stock/cash/option
|
||
/// A lot that gained `close_date` (and normally `close_price`) in
|
||
/// place: the position was sold, but the record was kept rather
|
||
/// than deleted - either edited where it sat, or archived into a
|
||
/// sibling `portfolio_closed.srf` that the same glob picks up.
|
||
///
|
||
/// Needs its own kind because the strict lot key deliberately
|
||
/// excludes `close_date`, so a close leaves the key and the share
|
||
/// count untouched. Without this the change fell through the
|
||
/// same-shares branch (which only inspects `price` and
|
||
/// `maturity_date`) and emitted nothing at all, making the sale
|
||
/// invisible to attribution and letting the repurchase read as a
|
||
/// fresh contribution.
|
||
///
|
||
/// Valued at `close_price` - the only figure that records what the
|
||
/// sale actually realized, and strictly better than the current-price
|
||
/// proxy `lot_removed` has to settle for. Contributes $0 to
|
||
/// attribution and acts as a funding source, exactly like
|
||
/// `lot_removed`.
|
||
position_closed,
|
||
/// Strict lot key broke (open_date / open_price / symbol rewritten)
|
||
/// but the same (security_type, priceSymbol, account) reappears on
|
||
/// the other side with approximately the same share total.
|
||
/// Classified as a lot edit - not counted as contribution, not
|
||
/// counted as disposal. Fixes the phantom-contribution bug from
|
||
/// reconciliation tweaks, CD auto-renewals rewriting `open_date`,
|
||
/// and symbol-alias rewrites (`symbol::SPY` -> `symbol::DI-SPX,
|
||
/// ticker::SPY`).
|
||
///
|
||
/// Deliberately does NOT cover account renames: `secondaryKey`
|
||
/// includes the account, so renaming one breaks both keys and the
|
||
/// rename is indistinguishable from closing one account and opening
|
||
/// another with the same positions. See the "account rename is NOT
|
||
/// collapsed" test below and the TODO.md entry for the known fix.
|
||
lot_edited,
|
||
price_only, // same key, price:: field changed, no share change
|
||
flagged, // any other shape of edit
|
||
|
||
// ── Transfer reclassifications (see `matchTransfers`) ────
|
||
/// Destination lot or cash_delta fully attributed to a transfer
|
||
/// record in `transaction_log.srf`. Contributes $0 to attribution -
|
||
/// it's internal money movement, not new contribution. Replaces
|
||
/// the `new_stock` / `new_drip_lot` / `new_cash` / `new_cd` /
|
||
/// `cash_contribution` classification the lot would otherwise
|
||
/// receive.
|
||
transfer_in,
|
||
/// `lot_removed` or negative `cash_delta` on the sending account,
|
||
/// credited against a transfer record. Contributes $0 to
|
||
/// attribution. Also replaces the base classification.
|
||
transfer_out,
|
||
/// Destination lot or cash partially attributed to a transfer.
|
||
/// The remaining value (`c.value() - c.transfer_attributed`) is
|
||
/// still real new money and flows through attribution under the
|
||
/// base classification's bucket (new_contributions or drip).
|
||
partial_transfer_in,
|
||
/// Transfer record in the window that couldn't be matched to any
|
||
/// portfolio-diff Change. Emitted as a standalone Change (not
|
||
/// attached to an existing lot) so it surfaces in the Flagged
|
||
/// section. `detail` carries the human-readable reason.
|
||
/// Contributes $0 to attribution.
|
||
unmatched_transfer,
|
||
};
|
||
|
||
const Change = struct {
|
||
kind: ChangeKind,
|
||
symbol: []const u8,
|
||
account: []const u8,
|
||
security_type: LotType,
|
||
/// Δshares (after - before). Zero for price_only and lot_removed.
|
||
delta_shares: f64 = 0,
|
||
/// Price used to value delta_shares (open_price, current price, or manual price).
|
||
unit_value: f64 = 0,
|
||
/// For cd_matured / cd_removed_early / lot_removed: the dollar
|
||
/// value that left, i.e. `before_shares * unit_value`. For a cash
|
||
/// lot that is shares 1:1; for a CD it is the face value; for a
|
||
/// stock it is the sale proceeds at the best available price.
|
||
face_value: f64 = 0,
|
||
/// For cd_matured / cd_removed_early: maturity_date.
|
||
maturity_date: ?Date = null,
|
||
/// For price_only: old and new values.
|
||
old_price: f64 = 0,
|
||
new_price: f64 = 0,
|
||
/// Free-form detail for flagged changes.
|
||
detail: ?[]const u8 = null,
|
||
/// Lot open_date for new_* kinds - carried here so downstream
|
||
/// consumers (the audit large-lot warning, the Transfers section
|
||
/// printer) can generate `transfer_log.srf` templates without
|
||
/// re-reading the after-portfolio. Null for non-new kinds.
|
||
open_date: ?Date = null,
|
||
|
||
// ── Transfer reclassification (see `matchTransfers`) ────
|
||
/// Dollar amount of this Change's `value()` that's attributable
|
||
/// to a matched transfer record. Set only for `transfer_in`
|
||
/// (equals `value()`), `partial_transfer_in` (less than `value()`),
|
||
/// and `unmatched_transfer` (equals the record's amount, carried
|
||
/// on a synthetic Change). Zero otherwise.
|
||
transfer_attributed: f64 = 0,
|
||
/// Free-form text carried from the transfer record (its `note::`
|
||
/// field) or from the matcher (for `unmatched_transfer`, a
|
||
/// reason string).
|
||
transfer_note: ?[]const u8 = null,
|
||
/// Sending account for transfer_in / partial_transfer_in /
|
||
/// unmatched_transfer. Null for non-transfer kinds.
|
||
transfer_from: ?[]const u8 = null,
|
||
/// Date of the matched transfer record. Null for non-transfer
|
||
/// kinds.
|
||
transfer_date: ?Date = null,
|
||
|
||
// ── Intra-account purchase netting (see `matchIntraAccountPurchases`) ────
|
||
/// Portion of this Change's `value()` funded by money that was
|
||
/// already inside the SAME account - not a fresh contribution.
|
||
/// `attributedValue()` subtracts it, so a fully-funded change 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. Two funding sources, both same-account:
|
||
///
|
||
/// - **cash that visibly left** (a negative `cash_delta` or a
|
||
/// removed cash lot) funding a `new_stock` / `new_cd` buy;
|
||
/// - **proceeds of a security sale** - a valued `lot_removed` /
|
||
/// `drip_negative` - or of a CD paying out (`cd_matured` /
|
||
/// `cd_removed_early`). Proceeds that were spent fund `new_stock` /
|
||
/// `new_cd`; proceeds still sitting in cash at window end
|
||
/// instead cancel a `cash_contribution` on that account, which
|
||
/// would otherwise book the sale as new money (see
|
||
/// `matchIntraAccountPurchases` for the spent/resting split).
|
||
///
|
||
/// So the kinds that can carry this are `new_stock`, `new_cd`, and
|
||
/// `cash_contribution`.
|
||
///
|
||
/// 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;
|
||
}
|
||
|
||
/// Portion of `value()` that counts toward attribution (after
|
||
/// transfer reclassification). For `transfer_in` / `transfer_out`
|
||
/// / `unmatched_transfer`, the contribution is $0. For
|
||
/// `partial_transfer_in`, only the residual counts. For
|
||
/// `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. For `new_stock` /
|
||
/// `new_cd` / `cash_contribution`, `internal_funded` tracks how
|
||
/// much was funded from inside the same account - a cash decrease
|
||
/// or the proceeds of a security sale (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.
|
||
///
|
||
/// Outflows are pinned to 0 rather than falling through to
|
||
/// `value()`. They are a *funding source*, never a negative
|
||
/// contribution: the money did not leave the portfolio, it changed
|
||
/// form. Letting a valued `lot_removed` reach the `else` arm below
|
||
/// would subtract the sale from contributions and break the
|
||
/// `delta = contributions + gains` identity in the opposite
|
||
/// direction from the bug this valuation was added to fix.
|
||
pub fn attributedValue(self: Change) f64 {
|
||
return switch (self.kind) {
|
||
.transfer_in, .transfer_out, .unmatched_transfer => 0,
|
||
.lot_removed, .position_closed, .drip_negative, .cd_matured, .cd_removed_early => 0,
|
||
.partial_transfer_in => self.value() - self.transfer_attributed,
|
||
.new_cash, .cash_delta => self.value() - self.transfer_attributed,
|
||
.cash_contribution => self.value() - self.transfer_attributed - self.internal_funded,
|
||
.new_stock, .new_cd => self.value() - self.transfer_attributed - self.internal_funded,
|
||
else => self.value(),
|
||
};
|
||
}
|
||
};
|
||
|
||
/// Summary aggregated for the report. All string fields and backing memory
|
||
/// live in the caller-supplied arena; there is no explicit deinit.
|
||
const Report = struct {
|
||
changes: []Change,
|
||
/// Per-account rollups for the summary section.
|
||
account_totals: std.StringHashMap(AccountTotal),
|
||
|
||
const AccountTotal = struct {
|
||
new_money: f64 = 0, // stock+cd+cash new lots (drip::false)
|
||
drip_confirmed: f64 = 0, // confirmed DRIP (drip::true lots: new or shares increased)
|
||
rollup: f64 = 0, // share deltas on drip::false aggregate lots (DRIP or contribution; ambiguous)
|
||
cd_interest: f64 = 0, // implied interest from matured CDs
|
||
cash_delta: f64 = 0, // unclassified cash balance changes
|
||
};
|
||
};
|
||
|
||
/// Build a canonical lookup key for matching lots between snapshots.
|
||
/// Key: (security_type, symbol, account, open_date, open_price).
|
||
fn lotKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 {
|
||
return std.fmt.allocPrint(allocator, "{s}|{s}|{s}|{f}|{d:.6}", .{
|
||
@tagName(lot.security_type),
|
||
lot.symbol,
|
||
lot.account orelse "",
|
||
lot.open_date,
|
||
lot.open_price,
|
||
});
|
||
}
|
||
|
||
/// Every lot sharing one `lotKey`, summed. Duplicates are common, not
|
||
/// rare: recording a partial sale SPLITS a lot into a held part and a
|
||
/// sold part, and both keep the original key (the key excludes
|
||
/// `close_date` on purpose - a close must not look like a new lot). The
|
||
/// sold part is often archived to `portfolio_closed.srf`, which the
|
||
/// same glob merges back in.
|
||
///
|
||
/// So the sold shares are tracked separately. Folding them into one
|
||
/// count made a split either invisible or valued at the full share
|
||
/// count, depending on which half happened to come first in the file.
|
||
const LotAgg = struct {
|
||
/// All shares under the key, sold or not.
|
||
shares: f64,
|
||
/// The part of `shares` sold on or before `as_of`
|
||
/// (`Lot.isSoldAsOf`).
|
||
sold_shares: f64 = 0,
|
||
/// What those sold shares realized (`closeUnitValue` x shares).
|
||
sold_value: f64 = 0,
|
||
/// Representative lot: the first unsold one when there is any, so
|
||
/// type-, maturity- and drip-dependent decisions describe the part
|
||
/// still held.
|
||
lot: Lot,
|
||
|
||
fn unsold(self: LotAgg) f64 {
|
||
return self.shares - self.sold_shares;
|
||
}
|
||
};
|
||
|
||
fn aggregateByKey(
|
||
allocator: std.mem.Allocator,
|
||
lots: []const Lot,
|
||
prices: *const std.StringHashMap(f64),
|
||
as_of: Date,
|
||
) !std.StringHashMap(LotAgg) {
|
||
var map = std.StringHashMap(LotAgg).init(allocator);
|
||
|
||
for (lots) |lot| {
|
||
const k = try lotKey(allocator, lot);
|
||
const gop = try map.getOrPut(k);
|
||
const sold = lot.isSoldAsOf(as_of);
|
||
if (!gop.found_existing) {
|
||
gop.value_ptr.* = .{ .shares = 0, .lot = lot };
|
||
} else {
|
||
allocator.free(k);
|
||
if (!sold and gop.value_ptr.lot.isSoldAsOf(as_of)) gop.value_ptr.lot = lot;
|
||
}
|
||
gop.value_ptr.shares += lot.shares;
|
||
if (sold) {
|
||
gop.value_ptr.sold_shares += lot.shares;
|
||
gop.value_ptr.sold_value += lot.shares * closeUnitValue(lot, prices, as_of);
|
||
}
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/// How one key's shares changed between the two sides, split into the
|
||
/// part that was SOLD and whatever else happened.
|
||
///
|
||
/// sold shares that moved into the sold bucket - a sale event,
|
||
/// valued from `sold_value`. Signed like the lots (negative
|
||
/// for a written option). Zero when no sale happened.
|
||
/// residual every other change to the held shares - a buy, a DRIP
|
||
/// top-up, an unrecorded reduction - classified exactly as
|
||
/// a share change always was.
|
||
///
|
||
/// Sold shares that DISAPPEAR are not a sale in reverse. Deleting a
|
||
/// closed lot is housekeeping, and removing a mistyped `close_date`
|
||
/// ("reopening") moves shares back to held with nothing bought; both
|
||
/// net out to nothing.
|
||
fn splitShareChange(before: LotAgg, after: LotAgg) struct { sold: f64, residual: f64 } {
|
||
const held_delta = after.unsold() - before.unsold();
|
||
if (@abs(after.sold_shares) > @abs(before.sold_shares)) {
|
||
const moved = after.sold_shares - before.sold_shares;
|
||
// The sale drew `moved` shares out of held; only what's left
|
||
// over is a separate change.
|
||
return .{ .sold = moved, .residual = held_delta + moved };
|
||
}
|
||
// Sold shares that vanished (`gone`) either reopened - came back as
|
||
// held - or were deleted. Only the part of the held change moving
|
||
// the same way can be a reopen, and at most `gone` of it.
|
||
const gone = before.sold_shares - after.sold_shares;
|
||
const reopened = if (std.math.sign(held_delta) == std.math.sign(gone))
|
||
std.math.sign(gone) * @min(@abs(held_delta), @abs(gone))
|
||
else
|
||
0;
|
||
return .{ .sold = 0, .residual = held_delta - reopened };
|
||
}
|
||
|
||
fn testAgg(shares: f64, sold: f64) LotAgg {
|
||
return .{ .shares = shares, .sold_shares = sold, .lot = .{ .symbol = "X", .shares = shares, .open_date = Date.epoch, .open_price = 1 } };
|
||
}
|
||
|
||
test "splitShareChange: a partial sale by splitting a lot is a sale and nothing else" {
|
||
const r = splitShareChange(testAgg(100, 0), testAgg(100, 40));
|
||
try std.testing.expectApproxEqAbs(@as(f64, 40), r.sold, 1e-9);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), r.residual, 1e-9);
|
||
}
|
||
|
||
test "splitShareChange: a whole-lot close" {
|
||
const r = splitShareChange(testAgg(100, 0), testAgg(100, 100));
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100), r.sold, 1e-9);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), r.residual, 1e-9);
|
||
}
|
||
|
||
test "splitShareChange: a written option closing keeps its sign" {
|
||
const r = splitShareChange(testAgg(-2, 0), testAgg(-2, -2));
|
||
try std.testing.expectApproxEqAbs(@as(f64, -2), r.sold, 1e-9);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), r.residual, 1e-9);
|
||
}
|
||
|
||
test "splitShareChange: deleting a sold part is housekeeping" {
|
||
const r = splitShareChange(testAgg(100, 40), testAgg(60, 0));
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), r.sold, 1e-9);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), r.residual, 1e-9);
|
||
}
|
||
|
||
test "splitShareChange: removing a mistyped close_date is a reopen, not a buy" {
|
||
const r = splitShareChange(testAgg(100, 40), testAgg(100, 0));
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), r.sold, 1e-9);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), r.residual, 1e-9);
|
||
}
|
||
|
||
test "splitShareChange: a sale and a separate top-up in one window" {
|
||
// 40 sold, and 5 more shares bought into the held part.
|
||
const r = splitShareChange(testAgg(100, 0), testAgg(105, 40));
|
||
try std.testing.expectApproxEqAbs(@as(f64, 40), r.sold, 1e-9);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 5), r.residual, 1e-9);
|
||
}
|
||
|
||
test "splitShareChange: no sale leaves an ordinary share change alone" {
|
||
const r = splitShareChange(testAgg(100, 0), testAgg(90, 0));
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), r.sold, 1e-9);
|
||
try std.testing.expectApproxEqAbs(@as(f64, -10), r.residual, 1e-9);
|
||
}
|
||
|
||
/// Secondary key for edit detection: (security_type, priceSymbol, account).
|
||
/// Lots with the same secondary key but different strict `lotKey`s are
|
||
/// candidates for reclassification as `lot_edited` - the strict key
|
||
/// broke because `open_date`, `open_price`, or the underlying symbol
|
||
/// string got rewritten, but the position itself continued.
|
||
///
|
||
/// Uses `priceSymbol()` (ticker-alias-aware) rather than raw `symbol`
|
||
/// so that edits like `symbol::SPY` -> `symbol::DI-SPX, ticker::SPY`
|
||
/// collapse correctly. Both sides resolve to the same effective
|
||
/// ticker (`SPY`) and represent the same underlying exposure; the
|
||
/// raw `symbol` changed but the position did not.
|
||
fn secondaryKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 {
|
||
return std.fmt.allocPrint(allocator, "{s}|{s}|{s}", .{
|
||
@tagName(lot.security_type),
|
||
lot.priceSymbol(),
|
||
lot.account orelse "",
|
||
});
|
||
}
|
||
|
||
/// Suffix marking an edit group made of fully-sold keys. See
|
||
/// `editGroupKey`.
|
||
const sold_group_suffix = "|sold";
|
||
|
||
/// `secondaryKey`, split by whether the key still holds any shares.
|
||
///
|
||
/// Held and fully-sold keys must never pair as an edit. Deleting a
|
||
/// long-closed AMZN lot while buying new AMZN has one of each under the
|
||
/// same secondary key, and pairing them read as "one position edited" -
|
||
/// so the new buy vanished from attribution. Kept apart, the closed lot
|
||
/// is housekeeping (see `splitShareChange`) and the buy is a buy.
|
||
fn editGroupKey(allocator: std.mem.Allocator, agg: LotAgg) ![]u8 {
|
||
const sk = try secondaryKey(allocator, agg.lot);
|
||
defer allocator.free(sk);
|
||
const sold_only = @abs(agg.unsold()) <= 0.000001;
|
||
return std.fmt.allocPrint(allocator, "{s}{s}", .{ sk, if (sold_only) sold_group_suffix else "|held" });
|
||
}
|
||
|
||
/// Dollars-per-share for a lot that is LEAVING the portfolio (a whole
|
||
/// lot removed in pass 2, or a position closed in place). Multiplied
|
||
/// by the share count it gives the sale proceeds, which is what makes
|
||
/// a sale usable as a funding source for a same-account purchase.
|
||
///
|
||
/// A sale's true proceeds are only knowable from `close_price` (see
|
||
/// `closeUnitValue`); this is the fallback for a lot that simply
|
||
/// vanished from the file with no closing record. Current cached
|
||
/// price is the best available proxy - the diff windows are short
|
||
/// (typically one commit), so a recent sale prices close to the mark.
|
||
/// Manual `price::` then `open_price` follow; `open_price` is a cost
|
||
/// basis and understates a long-held winner, so it really is a last
|
||
/// resort.
|
||
///
|
||
/// `prices.get` returns the raw retail-class API price -> ratio
|
||
/// applies. `lot.price` and `lot.open_price` are already in the lot's
|
||
/// own share-class terms (preadjusted) -> ratio must NOT be applied.
|
||
/// See the "Pricing model" doc-block in models/portfolio.zig.
|
||
fn outflowUnitValue(lot: Lot, prices: *const std.StringHashMap(f64), as_of: Date) f64 {
|
||
return switch (lot.security_type) {
|
||
.stock => blk: {
|
||
if (prices.get(lot.priceSymbol())) |p| break :blk lot.effectivePrice(p, false);
|
||
if (lot.price) |p| break :blk lot.effectivePrice(p, true);
|
||
break :blk lot.effectivePrice(lot.open_price, true);
|
||
},
|
||
// Shares ARE dollars for cash lots.
|
||
.cash => 1.0,
|
||
// Face value per share.
|
||
.cd => lot.open_price,
|
||
// An option removed on or after its maturity expired, and an
|
||
// expired option is worth nothing: valuing it at its opening
|
||
// premium invented sale proceeds (a long call that died
|
||
// worthless "funded" part of the next buy) or, for a written
|
||
// one, a negative sale. If it was exercised or assigned, the
|
||
// stock and cash that moved show up as their own changes.
|
||
.option => if (lot.hasMaturedAsOf(as_of)) 0 else lot.open_price * lot.multiplier,
|
||
else => lot.open_price,
|
||
};
|
||
}
|
||
|
||
// ── Expired options (see `outflowUnitValue`) ──
|
||
|
||
fn testOption(shares: f64, account: []const u8) Lot {
|
||
return .{ .symbol = "SPY 01/16/2026 700 C", .shares = shares, .open_date = Date.fromYmd(2025, 6, 1), .open_price = 5, .security_type = .option, .maturity_date = Date.fromYmd(2026, 1, 16), .underlying = "SPY", .strike = 700, .account = account };
|
||
}
|
||
|
||
test "outflowUnitValue: an option deleted after it expired funds nothing" {
|
||
// A long call that died worthless used to be valued at its $500
|
||
// opening premium, and that phantom "sale" funded part of a real
|
||
// $2,000 fresh-money buy the same week - so only $1,500 counted.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(a);
|
||
const buy: Lot = .{ .symbol = "VTI", .shares = 8, .open_date = Date.fromYmd(2026, 1, 20), .open_price = 250, .account = "Sample Brokerage" };
|
||
const report = try computeReport(a, &.{testOption(1, "Sample Brokerage")}, &.{buy}, &prices, Date.fromYmd(2026, 1, 25), .{});
|
||
try std.testing.expectApproxEqAbs(@as(f64, 2000), attributionTotalForTest(report), 0.01);
|
||
}
|
||
|
||
test "outflowUnitValue: a written option expiring is no inflow either" {
|
||
// The mirror image. Negative shares made the old valuation a
|
||
// +$500 "uncounted inflow" and a "sold at mark (-$500.00)" line.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(a);
|
||
const report = try computeReport(a, &.{testOption(-1, "Sample Brokerage")}, &.{}, &prices, Date.fromYmd(2026, 1, 25), .{});
|
||
const unc = uncountedTotals(report.changes);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), unc.in, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), unc.out, 0.01);
|
||
}
|
||
|
||
test "outflowUnitValue: an option deleted BEFORE expiry still funds at its premium" {
|
||
// Only expiry zeroes it. Closing a position early (deleting it
|
||
// before maturity) keeps the existing cost-basis proxy.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(a);
|
||
const buy: Lot = .{ .symbol = "VTI", .shares = 8, .open_date = Date.fromYmd(2026, 1, 10), .open_price = 250, .account = "Sample Brokerage" };
|
||
const report = try computeReport(a, &.{testOption(1, "Sample Brokerage")}, &.{buy}, &prices, Date.fromYmd(2026, 1, 12), .{});
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1500), attributionTotalForTest(report), 0.01);
|
||
}
|
||
|
||
/// Emit a `position_closed` for `sold` shares that realized `proceeds`.
|
||
///
|
||
/// The one place a sale Change is built, whether it came from a lot
|
||
/// closed in place, a lot split into held and sold parts, or a lot that
|
||
/// arrived already sold. `sold` is signed like the lots (negative for a
|
||
/// written option); `unit_value` is recovered so `value()` stays the
|
||
/// negative proceeds, as for every other outflow.
|
||
fn appendSale(
|
||
allocator: std.mem.Allocator,
|
||
changes: *std.ArrayList(Change),
|
||
symbol: []const u8,
|
||
account: []const u8,
|
||
security_type: LotType,
|
||
sold: f64,
|
||
proceeds: f64,
|
||
) !void {
|
||
try changes.append(allocator, .{
|
||
.kind = .position_closed,
|
||
.symbol = symbol,
|
||
.account = account,
|
||
.security_type = security_type,
|
||
.unit_value = proceeds / sold,
|
||
.face_value = proceeds,
|
||
.delta_shares = -sold,
|
||
});
|
||
}
|
||
|
||
/// Dollars-per-share realized when a lot was closed in place.
|
||
///
|
||
/// `close_price` is authoritative - it is what the sale actually got,
|
||
/// recorded at the time by whoever closed the lot - so it wins
|
||
/// outright. Cost basis would understate a long-held winner badly
|
||
/// (on the trade that motivated this, by $48,963.65 on $467k), which
|
||
/// is why the fallback defers to `outflowUnitValue`'s current-price
|
||
/// proxy rather than reaching for `open_price`.
|
||
fn closeUnitValue(lot: Lot, prices: *const std.StringHashMap(f64), as_of: Date) f64 {
|
||
if (lot.closePriceAsOf(as_of)) |cp| {
|
||
return switch (lot.security_type) {
|
||
// close_price is in the lot's own share-class terms
|
||
// (preadjusted), so the ratio must NOT be applied again.
|
||
.stock => lot.effectivePrice(cp, true),
|
||
.option => cp * lot.multiplier,
|
||
else => cp,
|
||
};
|
||
}
|
||
return outflowUnitValue(lot, prices, as_of);
|
||
}
|
||
|
||
/// Tolerance for "did the share total stay the same" check when
|
||
/// deciding whether to emit a rollup_delta for any residual share
|
||
/// difference alongside a `lot_edited` classification. Anything within
|
||
/// this tolerance is treated as rounding/reconciliation noise and
|
||
/// suppressed; anything beyond it surfaces as a normal share-delta
|
||
/// change (rollup_delta for positive, drip_negative for negative).
|
||
///
|
||
/// This is NOT a gate on whether the pair collapses - secondary-key
|
||
/// matches ALWAYS collapse the identity change into a lot_edited
|
||
/// record. The tolerance only decides whether to additionally emit
|
||
/// the residual share movement as a distinct change.
|
||
const edit_residual_tolerance_rel: f64 = 0.0001; // 0.01%
|
||
|
||
/// Looser tolerance applied to accounts flagged
|
||
/// `direct_indexing::true` in `accounts.srf`. Direct-indexing
|
||
/// proxies (a basket of underlying stocks tracked as a single
|
||
/// benchmark via `ticker::`) have tracking-error drift that legitimately
|
||
/// moves the basket's equivalent share count without any real money
|
||
/// flowing. A tighter tolerance flags that drift as contribution-
|
||
/// adjacent noise; this looser one treats it as edit-only, so the
|
||
/// attribution line stays clean of tracking-error reconciliation.
|
||
///
|
||
/// 1% is chosen to sit well above typical weekly tracking-error
|
||
/// magnitudes (< 0.5% in normal markets) while still catching
|
||
/// out-of-band moves - e.g. an actual $100k contribution into a
|
||
/// multi-million direct-indexing basket (~1.2% of the account) will
|
||
/// fall just over this threshold and surface as a rollup_delta for
|
||
/// review. Not configurable per-account today; revisit if anyone's
|
||
/// direct-indexing account generates > 1% drift regularly.
|
||
const direct_indexing_residual_tolerance_rel: f64 = 0.01; // 1%
|
||
|
||
/// Identify strict-key pairs that should be reclassified as edits.
|
||
///
|
||
/// Walks the "only in after" and "only in before" strict keys, groups
|
||
/// each by secondary key (security_type, priceSymbol, account), and
|
||
/// returns a set of strict keys that should be skipped by the primary
|
||
/// classification passes (both `new_*` and `lot_removed` / `cd_*`).
|
||
/// Populates `changes` with a single `lot_edited` entry per matched
|
||
/// secondary-key group, plus a residual `rollup_delta` / `drip_negative`
|
||
/// for any share difference beyond noise tolerance.
|
||
///
|
||
/// Matching rules:
|
||
/// - A secondary key must have at least one unmatched lot on each
|
||
/// side (otherwise it's a pure add or a pure remove, which keeps
|
||
/// the existing semantics).
|
||
/// - All unmatched strict keys for that secondary key group collapse
|
||
/// together regardless of share-total magnitude. The thinking:
|
||
/// secondary-key agreement (same `(security_type, priceSymbol,
|
||
/// account)`) means the user is editing the same underlying
|
||
/// position. Any share difference is a separate question -
|
||
/// handled by emitting a residual rollup_delta (if positive) or
|
||
/// drip_negative (if negative). This matches how share deltas on
|
||
/// intact strict keys are classified in Pass 1.
|
||
fn detectEdits(
|
||
allocator: std.mem.Allocator,
|
||
before_map: *const std.StringHashMap(LotAgg),
|
||
after_map: *const std.StringHashMap(LotAgg),
|
||
prices: *const std.StringHashMap(f64),
|
||
account_map: ?*const analysis.AccountMap,
|
||
changes: *std.ArrayList(Change),
|
||
) !std.StringHashMap(void) {
|
||
var skip = std.StringHashMap(void).init(allocator);
|
||
errdefer skip.deinit();
|
||
|
||
// Group unmatched strict keys by secondary key, tagging each entry
|
||
// with the side it came from. `strict_keys` alongside the value
|
||
// lets us seed the skip set only when a group actually matches.
|
||
const SidedEntry = struct {
|
||
strict_key: []const u8,
|
||
agg: LotAgg,
|
||
from_after: bool,
|
||
};
|
||
var groups = std.StringHashMap(std.ArrayList(SidedEntry)).init(allocator);
|
||
defer {
|
||
var vit = groups.valueIterator();
|
||
while (vit.next()) |list| list.deinit(allocator);
|
||
groups.deinit();
|
||
}
|
||
|
||
var ait = after_map.iterator();
|
||
while (ait.next()) |entry| {
|
||
if (before_map.contains(entry.key_ptr.*)) continue;
|
||
const sk = try editGroupKey(allocator, entry.value_ptr.*);
|
||
const gop = try groups.getOrPut(sk);
|
||
if (!gop.found_existing) {
|
||
gop.value_ptr.* = std.ArrayList(SidedEntry).empty;
|
||
} else {
|
||
allocator.free(sk);
|
||
}
|
||
try gop.value_ptr.append(allocator, .{
|
||
.strict_key = entry.key_ptr.*,
|
||
.agg = entry.value_ptr.*,
|
||
.from_after = true,
|
||
});
|
||
}
|
||
|
||
var bit = before_map.iterator();
|
||
while (bit.next()) |entry| {
|
||
if (after_map.contains(entry.key_ptr.*)) continue;
|
||
const sk = try editGroupKey(allocator, entry.value_ptr.*);
|
||
const gop = try groups.getOrPut(sk);
|
||
if (!gop.found_existing) {
|
||
gop.value_ptr.* = std.ArrayList(SidedEntry).empty;
|
||
} else {
|
||
allocator.free(sk);
|
||
}
|
||
try gop.value_ptr.append(allocator, .{
|
||
.strict_key = entry.key_ptr.*,
|
||
.agg = entry.value_ptr.*,
|
||
.from_after = false,
|
||
});
|
||
}
|
||
|
||
// Dup helper for string arena storage into `changes`.
|
||
const Dup = struct {
|
||
a: std.mem.Allocator,
|
||
fn of(self: @This(), s: []const u8) ![]const u8 {
|
||
return self.a.dupe(u8, s);
|
||
}
|
||
};
|
||
const sdup = Dup{ .a = allocator };
|
||
|
||
var git_it = groups.iterator();
|
||
while (git_it.next()) |entry| {
|
||
const list = entry.value_ptr.*;
|
||
|
||
// A group of fully-sold keys is an edit to history (a closed
|
||
// lot's cost corrected, say). It pairs by sold shares and never
|
||
// carries a residual: nothing held changed.
|
||
const sold_group = std.mem.endsWith(u8, entry.key_ptr.*, sold_group_suffix);
|
||
|
||
// Need at least one on each side to be an edit.
|
||
var after_shares: f64 = 0;
|
||
var before_shares: f64 = 0;
|
||
var after_rep: ?LotAgg = null;
|
||
var before_rep: ?LotAgg = null;
|
||
for (list.items) |e| {
|
||
const n = if (sold_group) e.agg.sold_shares else e.agg.unsold();
|
||
if (e.from_after) {
|
||
after_shares += n;
|
||
if (after_rep == null) after_rep = e.agg;
|
||
} else {
|
||
before_shares += n;
|
||
if (before_rep == null) before_rep = e.agg;
|
||
}
|
||
}
|
||
if (after_shares == 0 or before_shares == 0) continue;
|
||
|
||
// Qualified edit: add all strict keys to the skip set. Emit a
|
||
// lot_edited record representing the identity continuity.
|
||
for (list.items) |e| {
|
||
try skip.put(e.strict_key, {});
|
||
}
|
||
|
||
const rep_lot = after_rep.?.lot;
|
||
const acct = try sdup.of(rep_lot.account orelse "");
|
||
const sym = try sdup.of(rep_lot.symbol);
|
||
try changes.append(allocator, .{
|
||
.kind = .lot_edited,
|
||
.symbol = sym,
|
||
.account = acct,
|
||
.security_type = rep_lot.security_type,
|
||
.delta_shares = 0,
|
||
.unit_value = 0,
|
||
});
|
||
if (sold_group) continue;
|
||
|
||
// Emit a residual share-delta change if the totals diverge
|
||
// beyond noise. Mirrors Pass 1's same-key share-delta handling
|
||
// so the user sees the same classification whether the strict
|
||
// key was preserved or rewritten.
|
||
//
|
||
// Direct-indexing accounts (flagged `direct_indexing::true`
|
||
// in accounts.srf) use a looser tolerance - tracking-error
|
||
// share reconciliation on a proxy basket isn't real money
|
||
// flow and shouldn't land in rollup_delta / drip_negative.
|
||
const delta = after_shares - before_shares;
|
||
const denom = @max(@abs(after_shares), @abs(before_shares));
|
||
const rel = if (denom == 0) 0.0 else @abs(delta) / denom;
|
||
const tolerance = if (account_map) |am|
|
||
(if (am.isDirectIndexing(rep_lot.account orelse ""))
|
||
direct_indexing_residual_tolerance_rel
|
||
else
|
||
edit_residual_tolerance_rel)
|
||
else
|
||
edit_residual_tolerance_rel;
|
||
if (rel <= tolerance) continue;
|
||
|
||
const unit_value: f64 = blk: {
|
||
if (rep_lot.security_type == .stock) {
|
||
// `prices.get` is the raw retail-class API price -> ratio applies.
|
||
// `lot.price` (manual override) and `lot.open_price` are both
|
||
// in the lot's own share-class terms (preadjusted) -> ratio
|
||
// must NOT be applied. See the "Pricing model" doc-block in
|
||
// models/portfolio.zig.
|
||
if (prices.get(rep_lot.priceSymbol())) |p| break :blk rep_lot.effectivePrice(p, false);
|
||
if (rep_lot.price) |p| break :blk rep_lot.effectivePrice(p, true);
|
||
break :blk rep_lot.effectivePrice(rep_lot.open_price, true);
|
||
}
|
||
break :blk 1.0;
|
||
};
|
||
|
||
const is_drip = rep_lot.drip or (before_rep.?.lot.drip);
|
||
const kind: ChangeKind = switch (rep_lot.security_type) {
|
||
.stock => if (delta > 0)
|
||
(if (is_drip) ChangeKind.drip_confirmed else ChangeKind.rollup_delta)
|
||
else
|
||
ChangeKind.drip_negative,
|
||
.cash => .cash_delta,
|
||
else => .flagged,
|
||
};
|
||
try changes.append(allocator, .{
|
||
.kind = kind,
|
||
.symbol = sym,
|
||
.account = acct,
|
||
.security_type = rep_lot.security_type,
|
||
.delta_shares = delta,
|
||
.unit_value = unit_value,
|
||
});
|
||
}
|
||
|
||
return skip;
|
||
}
|
||
|
||
/// Optional knobs for `computeReport`. An explicit struct keeps the
|
||
/// call-site noise low at the many in-file test call sites (all pass
|
||
/// `.{}`) while still letting production callers thread through the
|
||
/// account map for opted-in cash-delta reclassification.
|
||
const ReportOptions = struct {
|
||
/// Account metadata from `accounts.srf`. When present, positive
|
||
/// `cash_delta` entries for accounts with
|
||
/// `cash_is_contribution::true` are reclassified as
|
||
/// `cash_contribution` so every downstream consumer (full
|
||
/// report, per-account summary, `compare` attribution) agrees.
|
||
account_map: ?*const analysis.AccountMap = null,
|
||
/// Transfer records to match against this diff. Typically the
|
||
/// records that are NEW in the after-side `transaction_log.srf`
|
||
/// relative to the before-side (computed by
|
||
/// `diffTransferLogs`). The matcher reclassifies
|
||
/// destination/source Changes as `transfer_in` /
|
||
/// `partial_transfer_in` / `transfer_out` and emits
|
||
/// `unmatched_transfer` entries for records that can't be
|
||
/// matched. When null or empty, `matchTransfers` is a no-op.
|
||
///
|
||
/// Records are not date-window filtered: the caller is
|
||
/// responsible for passing only records that should be
|
||
/// considered for THIS diff. The expected source is "records
|
||
/// added to `transaction_log.srf` since `before_rev`," not
|
||
/// "records dated within the diff's git timestamp window."
|
||
/// See `diffTransferLogs` and the prepareReport pipeline for
|
||
/// how the production caller assembles the slice.
|
||
transfer_log: ?[]const transaction_log.TransferRecord = null,
|
||
};
|
||
|
||
fn computeReport(
|
||
allocator: std.mem.Allocator,
|
||
before: []const Lot,
|
||
after: []const Lot,
|
||
prices: *const std.StringHashMap(f64),
|
||
as_of: Date,
|
||
opts: ReportOptions,
|
||
) !Report {
|
||
var changes: std.ArrayList(Change) = .empty;
|
||
|
||
var before_map = try aggregateByKey(allocator, before, prices, as_of);
|
||
var after_map = try aggregateByKey(allocator, after, prices, as_of);
|
||
|
||
// Edit detection: identify strict-key pairs that look like edits
|
||
// (reconciliation tweak, CD auto-renewal rewriting `open_date`,
|
||
// symbol alias rewrite like SPY->DI-SPX,ticker::SPY) rather than
|
||
// real new/removed lots. The returned `skip` set is the list of
|
||
// strict keys passes 1 and 2 should ignore - each matched group
|
||
// emits its own `lot_edited` change plus a residual rollup for
|
||
// any share delta beyond noise.
|
||
var skip = try detectEdits(allocator, &before_map, &after_map, prices, opts.account_map, &changes);
|
||
defer skip.deinit();
|
||
|
||
// Helper for duping strings into the arena so Change fields have
|
||
// predictable lifetimes even if caller-supplied lot strings go away.
|
||
const Dup = struct {
|
||
a: std.mem.Allocator,
|
||
fn of(self: @This(), s: []const u8) ![]const u8 {
|
||
return self.a.dupe(u8, s);
|
||
}
|
||
};
|
||
const sdup = Dup{ .a = allocator };
|
||
|
||
// Pass 1: keys in after. Classify as new, shares-changed, or (matched
|
||
// with price-only or other-metadata changes) edited.
|
||
var ait = after_map.iterator();
|
||
while (ait.next()) |entry| {
|
||
if (skip.contains(entry.key_ptr.*)) continue;
|
||
const after_agg = entry.value_ptr.*;
|
||
if (before_map.get(entry.key_ptr.*)) |before_agg| {
|
||
// Key present in both. Split the share change into a sale
|
||
// (shares that became sold) and everything else.
|
||
const split = splitShareChange(before_agg, after_agg);
|
||
const lot = after_agg.lot;
|
||
const acct = try sdup.of(lot.account orelse "");
|
||
const sym = try sdup.of(lot.symbol);
|
||
|
||
// The sale, valued at what the sold shares realized. It is
|
||
// checked independently of the residual and wins over the
|
||
// metadata comparisons below: a lot both closed and repriced
|
||
// in one window is a sale, not a price edit.
|
||
if (@abs(split.sold) > 0.000001) {
|
||
try appendSale(allocator, &changes, sym, acct, lot.security_type, split.sold, after_agg.sold_value - before_agg.sold_value);
|
||
}
|
||
|
||
const delta = split.residual;
|
||
if (@abs(delta) > 0.000001) {
|
||
// Direct-indexing suppression: for stock lots in
|
||
// flagged accounts, sub-1% share drift is tracking-
|
||
// error reconciliation, not real money flow. Skip
|
||
// emitting a rollup_delta / drip_negative so the
|
||
// attribution stays clean. Must apply the same
|
||
// logic here as in `detectEdits` or the treatment
|
||
// becomes inconsistent depending on whether the
|
||
// strict key broke this week.
|
||
if (lot.security_type == .stock) {
|
||
const denom = @max(@abs(after_agg.shares), @abs(before_agg.shares));
|
||
const rel = if (denom == 0) 0.0 else @abs(delta) / denom;
|
||
const is_di = if (opts.account_map) |am| am.isDirectIndexing(lot.account orelse "") else false;
|
||
if (is_di and rel <= direct_indexing_residual_tolerance_rel) continue;
|
||
}
|
||
|
||
const before_lot = before_agg.lot;
|
||
const is_drip = lot.drip or before_lot.drip;
|
||
const base_kind: ChangeKind = switch (lot.security_type) {
|
||
.stock => if (delta > 0)
|
||
(if (is_drip) ChangeKind.drip_confirmed else ChangeKind.rollup_delta)
|
||
else
|
||
ChangeKind.drip_negative,
|
||
.cash => .cash_delta,
|
||
.cd => .flagged, // CD face value shouldn't change on the same key
|
||
.option => .flagged,
|
||
else => .flagged,
|
||
};
|
||
// Opt-in reclassification: on accounts marked
|
||
// `cash_is_contribution::true` in accounts.srf, a
|
||
// positive cash_delta is actually new money arriving
|
||
// (payroll ESPP accrual, direct 401k cash deposit,
|
||
// etc.). Reclassify at this single point so every
|
||
// downstream consumer - full report, per-account
|
||
// summary, `compare` attribution - sees the same
|
||
// classification. Negative cash_delta stays as noise
|
||
// (a real withdrawal would need different semantics).
|
||
const kind: ChangeKind = if (base_kind == .cash_delta and delta > 0) blk: {
|
||
if (opts.account_map) |am| {
|
||
if (am.cashIsContribution(lot.account orelse "")) break :blk .cash_contribution;
|
||
}
|
||
break :blk .cash_delta;
|
||
} else base_kind;
|
||
// Determine unit_value for stocks: prefer current cached price;
|
||
// fall back to manual price::; fall back to open_price.
|
||
//
|
||
// `prices.get` is the raw retail-class API price -> ratio applies.
|
||
// `lot.price` (manual override) and `lot.open_price` are both
|
||
// in the lot's own share-class terms (preadjusted) -> ratio
|
||
// must NOT be applied. See the "Pricing model" doc-block in
|
||
// models/portfolio.zig.
|
||
const unit_value: f64 = blk: {
|
||
if (lot.security_type == .stock) {
|
||
if (prices.get(lot.priceSymbol())) |p| break :blk lot.effectivePrice(p, false);
|
||
if (lot.price) |p| break :blk lot.effectivePrice(p, true);
|
||
break :blk lot.effectivePrice(lot.open_price, true);
|
||
}
|
||
// cash/cd: 1:1 with shares
|
||
break :blk 1.0;
|
||
};
|
||
try changes.append(allocator, .{
|
||
.kind = kind,
|
||
.symbol = sym,
|
||
.account = acct,
|
||
.security_type = lot.security_type,
|
||
.delta_shares = delta,
|
||
.unit_value = unit_value,
|
||
});
|
||
} else if (@abs(split.sold) <= 0.000001) {
|
||
// Same held shares and no sale: only metadata changed.
|
||
const before_lot = before_agg.lot;
|
||
const a_price = lot.price;
|
||
const b_price = before_lot.price;
|
||
if ((a_price != null) != (b_price != null) or
|
||
(a_price != null and b_price != null and @abs(a_price.? - b_price.?) > 0.000001))
|
||
{
|
||
try changes.append(allocator, .{
|
||
.kind = .price_only,
|
||
.symbol = sym,
|
||
.account = acct,
|
||
.security_type = lot.security_type,
|
||
.old_price = b_price orelse 0,
|
||
.new_price = a_price orelse 0,
|
||
});
|
||
} else if ((lot.maturity_date == null) != (before_lot.maturity_date == null) or
|
||
(lot.maturity_date != null and before_lot.maturity_date != null and
|
||
!lot.maturity_date.?.eql(before_lot.maturity_date.?)))
|
||
{
|
||
var old_buf: [10]u8 = undefined;
|
||
var new_buf: [10]u8 = undefined;
|
||
const old_str = if (before_lot.maturity_date) |d| (std.fmt.bufPrint(&old_buf, "{f}", .{d}) catch "????-??-??") else "(none)";
|
||
const new_str = if (lot.maturity_date) |d| (std.fmt.bufPrint(&new_buf, "{f}", .{d}) catch "????-??-??") else "(none)";
|
||
const detail = try std.fmt.allocPrint(allocator, "maturity_date {s} -> {s}", .{ old_str, new_str });
|
||
try changes.append(allocator, .{
|
||
.kind = .flagged,
|
||
.symbol = sym,
|
||
.account = acct,
|
||
.security_type = lot.security_type,
|
||
.detail = detail,
|
||
});
|
||
}
|
||
// Other edits (note, rate, etc.) are intentionally ignored to
|
||
// keep noise low.
|
||
}
|
||
} else {
|
||
// Key only in after -> new lot.
|
||
const lot = after_agg.lot;
|
||
const acct = try sdup.of(lot.account orelse "");
|
||
const sym = try sdup.of(lot.symbol);
|
||
const kind: ChangeKind = switch (lot.security_type) {
|
||
.stock => if (lot.drip) ChangeKind.new_drip_lot else ChangeKind.new_stock,
|
||
.cash => .new_cash,
|
||
.cd => .new_cd,
|
||
.option => .new_option,
|
||
else => .flagged,
|
||
};
|
||
// For fresh stock lots: value at open_price (that's literally the
|
||
// money that went in). For cash: shares == dollars. For CDs:
|
||
// face = shares × open_price.
|
||
//
|
||
// open_price is in the lot's own share-class terms (preadjusted),
|
||
// so route it through effectivePrice with is_preadjusted=true to
|
||
// avoid double-applying price_ratio. See the "Pricing model"
|
||
// doc-block in models/portfolio.zig.
|
||
const unit_value: f64 = switch (lot.security_type) {
|
||
.stock => lot.effectivePrice(lot.open_price, true),
|
||
.cash => 1.0,
|
||
.cd => lot.open_price,
|
||
.option => lot.open_price * lot.multiplier,
|
||
else => lot.open_price,
|
||
};
|
||
try changes.append(allocator, .{
|
||
.kind = kind,
|
||
.symbol = sym,
|
||
.account = acct,
|
||
.security_type = lot.security_type,
|
||
.delta_shares = after_agg.shares,
|
||
.unit_value = unit_value,
|
||
.open_date = lot.open_date,
|
||
});
|
||
|
||
// Arrived already (partly) sold: a round trip, emitted as the
|
||
// buy above plus this sale. Without the sale, backfilling an
|
||
// old closed lot booked its whole cost as a new contribution.
|
||
// With it, the funding matcher (`matchIntraAccountPurchases`)
|
||
// nets each case out correctly - and without window dates it
|
||
// can't tell a backfill from a trade made this week:
|
||
// - backfill: no cash moved, so the proceeds fund the buy
|
||
// and nothing counts;
|
||
// - bought and sold this week with new money: the proceeds
|
||
// are resting in cash, so the cost counts;
|
||
// - bought from existing cash and sold: the buy is funded,
|
||
// so nothing counts.
|
||
if (@abs(after_agg.sold_shares) > 0.000001) {
|
||
try appendSale(allocator, &changes, sym, acct, lot.security_type, after_agg.sold_shares, after_agg.sold_value);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pass 2: keys in before but not in after -> lot disappeared.
|
||
var bit = before_map.iterator();
|
||
while (bit.next()) |entry| {
|
||
if (after_map.contains(entry.key_ptr.*)) continue;
|
||
if (skip.contains(entry.key_ptr.*)) continue;
|
||
const before_agg = entry.value_ptr.*;
|
||
// Shares already sold were accounted for when the sale was
|
||
// recorded; deleting their line now is housekeeping. Valuing
|
||
// them at today's price invented proceeds that funded real
|
||
// purchases, hiding those contributions.
|
||
const held = before_agg.unsold();
|
||
if (@abs(held) <= 0.000001) continue;
|
||
const lot = before_agg.lot;
|
||
const acct = try sdup.of(lot.account orelse "");
|
||
const sym = try sdup.of(lot.symbol);
|
||
|
||
var kind: ChangeKind = .lot_removed;
|
||
if (lot.security_type == .cd) {
|
||
// Matured specifically, not "ended": a CD redeemed early
|
||
// (a past `close_date`) and then deleted was still removed
|
||
// early. No maturity at all is treated the same way.
|
||
kind = if (lot.hasMaturedAsOf(as_of)) .cd_matured else .cd_removed_early;
|
||
}
|
||
|
||
// Value the outflow. `unit_value` makes `value()` the (negative)
|
||
// dollar proceeds, which is what lets a security sale fund a
|
||
// same-account purchase in `matchIntraAccountPurchases`. Before
|
||
// this was populated, `value()` was 0 for every removal and a
|
||
// sale funded nothing - so an intra-account reallocation read as
|
||
// a full external contribution.
|
||
const unit_value = outflowUnitValue(lot, prices, as_of);
|
||
|
||
try changes.append(allocator, .{
|
||
.kind = kind,
|
||
.symbol = sym,
|
||
.account = acct,
|
||
.security_type = lot.security_type,
|
||
.unit_value = unit_value,
|
||
.face_value = held * unit_value,
|
||
.maturity_date = lot.maturity_date,
|
||
.delta_shares = -held,
|
||
});
|
||
}
|
||
|
||
// Transfer reclassification pass: rewrite destination/source
|
||
// Change kinds for records the caller passed in (typically the
|
||
// diff between before-side and after-side
|
||
// `transaction_log.srf`). No-op when no records are supplied. See
|
||
// `matchTransfers` docstring for the matching algorithm.
|
||
if (opts.transfer_log) |records| {
|
||
try matchTransfers(allocator, &changes, records);
|
||
}
|
||
|
||
// 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);
|
||
|
||
for (changes.items) |c| {
|
||
const gop = try acct_totals.getOrPut(c.account);
|
||
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||
switch (c.kind) {
|
||
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => {
|
||
// attributedValue() returns the unattributed residual
|
||
// for cash-kind Changes (matchCashDestination drained
|
||
// `transfer_attributed`); for non-cash kinds it equals
|
||
// value(). Either way, this is "real new money on the
|
||
// account."
|
||
gop.value_ptr.new_money += c.attributedValue();
|
||
},
|
||
.new_drip_lot, .drip_confirmed => {
|
||
gop.value_ptr.drip_confirmed += c.value();
|
||
},
|
||
.rollup_delta => {
|
||
gop.value_ptr.rollup += c.value();
|
||
},
|
||
.cash_delta => {
|
||
gop.value_ptr.cash_delta += c.attributedValue();
|
||
},
|
||
.cd_matured => {
|
||
// Interest is computed lazily against cash_delta in print();
|
||
// we don't add face value to new_money (that's not new money).
|
||
},
|
||
.partial_transfer_in => {
|
||
// Residual (value() - transfer_attributed) flows into
|
||
// new_money. The lot-destination matcher is the only
|
||
// producer of partial_transfer_in (cash-dest uses the
|
||
// per-account attribution bucket), so the residual
|
||
// represents pre-existing cash that funded part of
|
||
// the lot - a real contribution from the user.
|
||
gop.value_ptr.new_money += c.attributedValue();
|
||
},
|
||
.transfer_in, .transfer_out, .unmatched_transfer => {
|
||
// $0 contribution. Deliberately no-op.
|
||
},
|
||
else => {},
|
||
}
|
||
}
|
||
|
||
// Cash-dest transfer attribution is already removed by `attributedValue()` on
|
||
// each cash-side Change - the matcher accumulates into `transfer_attributed`,
|
||
// so the residual is what reaches these totals. Nothing further to subtract.
|
||
|
||
return .{
|
||
.changes = try changes.toOwnedSlice(allocator),
|
||
.account_totals = acct_totals,
|
||
};
|
||
}
|
||
|
||
// ── Sold-share tracking (see `LotAgg`, `splitShareChange`) ──
|
||
|
||
/// Run `computeReport` over `before`/`after` with VTI and AMZN priced,
|
||
/// in a caller-owned arena.
|
||
fn soldReport(a: std.mem.Allocator, before: []const Lot, after: []const Lot, opts: ReportOptions) !Report {
|
||
var prices = std.StringHashMap(f64).init(a);
|
||
try prices.put("VTI", 250.0);
|
||
try prices.put("AMZN", 200.0);
|
||
return computeReport(a, before, after, &prices, Date.fromYmd(2026, 1, 20), opts);
|
||
}
|
||
|
||
fn countKind(report: Report, kind: ChangeKind) usize {
|
||
var n: usize = 0;
|
||
for (report.changes) |c| {
|
||
if (c.kind == kind) n += 1;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
fn findKind(report: Report, kind: ChangeKind) ?Change {
|
||
for (report.changes) |c| {
|
||
if (c.kind == kind) return c;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const split_whole: Lot = .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 200, .account = "Sample Brokerage" };
|
||
|
||
fn splitHalves() [2]Lot {
|
||
var held = split_whole;
|
||
held.shares = 60;
|
||
var sold = split_whole;
|
||
sold.shares = 40;
|
||
sold.close_date = Date.fromYmd(2026, 1, 10);
|
||
sold.close_price = 260;
|
||
return .{ held, sold };
|
||
}
|
||
|
||
test "computeReport: a partial sale recorded by splitting a lot is a sale at close" {
|
||
// Both halves keep the original lot key. Summed as one count, the
|
||
// sale was invisible (held half first) or valued at all 100 shares,
|
||
// $26,000 (sold half first). It is 40 shares at $260.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
const h = splitHalves();
|
||
for ([_][2]Lot{ h, .{ h[1], h[0] } }) |order| {
|
||
const report = try soldReport(a, &.{split_whole}, &order, .{});
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
const sale = findKind(report, .position_closed) orelse return error.NoSale;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 10_400), sale.face_value, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, -40), sale.delta_shares, 1e-9);
|
||
}
|
||
}
|
||
|
||
test "computeReport: partial-sale proceeds fund a same-account buy" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
const h = splitHalves();
|
||
const buy: Lot = .{ .symbol = "AMZN", .shares = 50, .open_date = Date.fromYmd(2026, 1, 12), .open_price = 200, .account = "Sample Brokerage" };
|
||
const report = try soldReport(a, &.{split_whole}, &.{ h[0], h[1], buy }, .{});
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01);
|
||
}
|
||
|
||
test "computeReport: archiving the sold half to a sibling file changes nothing" {
|
||
// The union-merged view is the same whether both halves sit in one
|
||
// file or the sold one was moved to `portfolio_closed.srf`, so a
|
||
// later window that only moves it must be silent.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
const h = splitHalves();
|
||
const report = try soldReport(a, &.{ h[0], h[1] }, &.{ h[1], h[0] }, .{});
|
||
try std.testing.expectEqual(@as(usize, 0), report.changes.len);
|
||
}
|
||
|
||
test "computeReport: deleting a lot closed long ago is housekeeping" {
|
||
// It used to be revalued at today's price and treated as sale
|
||
// proceeds, which "funded" a real $2,000 fresh buy the same week.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
const old: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2022, 3, 15), .open_price = 150, .close_date = Date.fromYmd(2024, 1, 15), .close_price = 185.5, .account = "Sample Brokerage" };
|
||
const buy: Lot = .{ .symbol = "VTI", .shares = 8, .open_date = Date.fromYmd(2026, 1, 12), .open_price = 250, .account = "Sample Brokerage" };
|
||
const report = try soldReport(a, &.{old}, &.{buy}, .{});
|
||
try std.testing.expectEqual(@as(usize, 0), countKind(report, .lot_removed));
|
||
try std.testing.expectApproxEqAbs(@as(f64, 2000), attributionTotalForTest(report), 0.01);
|
||
}
|
||
|
||
test "computeReport: deleting a closed lot and buying the same symbol again isn't an edit" {
|
||
// Same secondary key (stock|AMZN|account). Paired as one edited
|
||
// position, the new buy disappeared.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
const old: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2022, 3, 15), .open_price = 150, .close_date = Date.fromYmd(2024, 1, 15), .close_price = 185.5, .account = "Sample Brokerage" };
|
||
const buy: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 12), .open_price = 200, .account = "Sample Brokerage" };
|
||
const report = try soldReport(a, &.{old}, &.{buy}, .{});
|
||
try std.testing.expectEqual(@as(usize, 0), countKind(report, .lot_edited));
|
||
try std.testing.expectApproxEqAbs(@as(f64, 2000), attributionTotalForTest(report), 0.01);
|
||
}
|
||
|
||
test "computeReport: correcting a closed lot's cost is a history edit, not a trade" {
|
||
// Both sides are fully sold under the same secondary key but a
|
||
// different strict key. That pairs as an edit with no residual; as
|
||
// a delete + new already-sold lot it would book the loss-making
|
||
// round trip's shortfall as new money.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
const was: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2022, 3, 15), .open_price = 150, .close_date = Date.fromYmd(2024, 1, 15), .close_price = 100, .account = "Sample Brokerage" };
|
||
var fixed = was;
|
||
fixed.open_price = 160;
|
||
const report = try soldReport(a, &.{was}, &.{fixed}, .{});
|
||
try std.testing.expectEqual(@as(usize, 1), countKind(report, .lot_edited));
|
||
try std.testing.expectEqual(@as(usize, 0), countKind(report, .position_closed));
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01);
|
||
}
|
||
|
||
test "computeReport: removing a mistyped close_date reopens the lot, buying nothing" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
const h = splitHalves();
|
||
const report = try soldReport(a, &.{ h[0], h[1] }, &.{split_whole}, .{});
|
||
try std.testing.expectEqual(@as(usize, 0), report.changes.len);
|
||
}
|
||
|
||
test "computeReport: backfilling an old closed lot is not a contribution" {
|
||
// It used to count its whole $1,500 cost as new money.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
const hist: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2022, 3, 15), .open_price = 150, .close_date = Date.fromYmd(2024, 1, 15), .close_price = 185.5, .account = "Sample Brokerage" };
|
||
const report = try soldReport(a, &.{}, &.{hist}, .{});
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01);
|
||
}
|
||
|
||
test "computeReport: a round trip inside the window counts the cost only when new money paid for it" {
|
||
// Emitted as buy + sale, the existing funding rules tell these apart
|
||
// by where the cash went - no window dates needed.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
const trade: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 12), .open_price = 150, .close_date = Date.fromYmd(2026, 1, 16), .close_price = 185.5, .account = "Sample Brokerage" };
|
||
const cash: Lot = .{ .symbol = "CASH", .shares = 2000, .open_date = Date.epoch, .open_price = 1, .security_type = .cash, .account = "Sample Brokerage" };
|
||
|
||
// New money: the $1,855 proceeds are resting in cash; the $1,500
|
||
// cost came from outside.
|
||
var cash_up = cash;
|
||
cash_up.shares = 2000 + 1855;
|
||
const new_money = try soldReport(a, &.{cash}, &.{ trade, cash_up }, .{});
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1500), attributionTotalForTest(new_money), 0.01);
|
||
|
||
// Existing cash: -$1,500 then +$1,855 nets to +$355.
|
||
var cash_net = cash;
|
||
cash_net.shares = 2000 + 355;
|
||
const from_cash = try soldReport(a, &.{cash}, &.{ trade, cash_net }, .{});
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(from_cash), 0.01);
|
||
}
|
||
|
||
// ── Transfer reclassification ────────────────────────────────
|
||
|
||
/// Absolute-dollar tolerance for matching a transfer record's `amount`
|
||
/// against a Change's `value()`. Within ±tolerance is a full match
|
||
/// (`transfer_in`); strictly below is a partial (`partial_transfer_in`);
|
||
/// strictly above is unmatched (with "amount exceeds ..." detail).
|
||
///
|
||
/// $1 is chosen to absorb typical reconciliation rounding (broker
|
||
/// statements often show cents, portfolio.srf may round to whole
|
||
/// 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
|
||
/// (they paired in their own diff cycle and shouldn't re-match).
|
||
///
|
||
/// `before` may be null (e.g. `transaction_log.srf` did not exist in
|
||
/// the before-side revision); in that case every record in `after`
|
||
/// is treated as new.
|
||
///
|
||
/// The returned slice is allocated in `arena` and its records
|
||
/// borrow from `after`'s record memory. Treat the slice as read-only
|
||
/// and tied to `after`'s lifetime.
|
||
///
|
||
/// Equality is `TransferRecord.eql` (total-field equality including
|
||
/// optional `note`). User edits to an existing record's any field
|
||
/// produce a "new" record from this function's POV; the matcher
|
||
/// then re-attempts pairing. See the `eql` doc comment for rationale.
|
||
fn diffTransferLogs(
|
||
arena: std.mem.Allocator,
|
||
before: ?*const transaction_log.TransactionLog,
|
||
after: *const transaction_log.TransactionLog,
|
||
) ![]const transaction_log.TransferRecord {
|
||
var out: std.ArrayList(transaction_log.TransferRecord) = .empty;
|
||
errdefer out.deinit(arena);
|
||
for (after.transfers) |a| {
|
||
var found = false;
|
||
if (before) |b| for (b.transfers) |bef| {
|
||
if (a.eql(bef)) {
|
||
found = true;
|
||
break;
|
||
}
|
||
};
|
||
if (!found) try out.append(arena, a);
|
||
}
|
||
return try out.toOwnedSlice(arena);
|
||
}
|
||
|
||
/// Reclassify Changes whose lot (or cash_delta) corresponds to a
|
||
/// recorded transfer. Walks `report.changes` and the in-window
|
||
/// transfer records together:
|
||
///
|
||
/// - 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
|
||
/// (account, symbol) and flip its kind to `transfer_in` (full)
|
||
/// or `partial_transfer_in` (partial), recording
|
||
/// `transfer_attributed` / `transfer_note`. Emit
|
||
/// `unmatched_transfer` if no such Change exists, if another
|
||
/// record already consumed it, or if `amount` exceeds the
|
||
/// lot's value by more than tolerance.
|
||
/// - If `dest_lot::cash`: verify the `to` account's pooled
|
||
/// positive cash activity (new_cash + cash_delta +
|
||
/// cash_contribution summed) can cover the record's amount
|
||
/// (minus any prior cash-dest records on the same account).
|
||
/// Success appends a synthetic `transfer_in` Change for
|
||
/// display AND accumulates onto the consumed cash Changes'
|
||
/// `transfer_attributed`, so `attributedValue()` reports only
|
||
/// the unattributed residual. Failure (budget underflow) emits
|
||
/// `unmatched_transfer`.
|
||
///
|
||
/// - For the `from` side: try to find a matching negative
|
||
/// `cash_delta` or `lot_removed` on the sending account and
|
||
/// credit the transfer amount against it, flipping to
|
||
/// `transfer_out`. A missing `from` side is NOT unmatched - the
|
||
/// sending account might not appear in portfolio.srf at all
|
||
/// (external account) or its outflow may be masked by unrelated
|
||
/// activity (dividend posting offsetting the withdrawal). Only
|
||
/// destination mismatches surface as unmatched_transfer.
|
||
///
|
||
/// Records outside the window (`transfer < window_start` or
|
||
/// `transfer > window_end`) are NOT filtered here. The caller is
|
||
/// responsible for narrowing the slice to records that should be
|
||
/// considered for THIS diff. The production path uses
|
||
/// `diffTransferLogs` to pass only the records added to
|
||
/// `transaction_log.srf` since `before_rev`, regardless of their
|
||
/// `transfer::DATE`. This allows a user to back-date a record
|
||
/// (e.g. add a `transfer::2026-05-20` entry on 2026-05-23) and
|
||
/// have it pair against the working-copy diff that introduced it.
|
||
fn matchTransfers(
|
||
allocator: std.mem.Allocator,
|
||
changes: *std.ArrayList(Change),
|
||
records: []const transaction_log.TransferRecord,
|
||
) !void {
|
||
// Bookkeeping: track which Change indices have already been
|
||
// claimed by a transfer record to catch duplicates on the
|
||
// lot-destination path.
|
||
var consumed_lot_idx: std.AutoHashMap(usize, void) = .init(allocator);
|
||
defer consumed_lot_idx.deinit();
|
||
|
||
// Per-account cash budget: sum of positive cash activity
|
||
// (new_cash + positive cash_delta + cash_contribution) on each
|
||
// account. Cash-dest records draw from this pool in record
|
||
// order. Underflow past tolerance -> unmatched_transfer.
|
||
var cash_budget: std.StringHashMap(f64) = .init(allocator);
|
||
defer cash_budget.deinit();
|
||
|
||
// Shortfalls carried out of the cash-destination path, per destination
|
||
// account, drawn down against new lots once every record has been seen.
|
||
// Deferred to the end so a second transfer into the same account can add to
|
||
// the same budget before any of it is spent.
|
||
var transfer_funding: std.StringHashMap(FundingShortfall) = .init(allocator);
|
||
defer transfer_funding.deinit();
|
||
for (changes.items) |c| {
|
||
const v = c.value();
|
||
switch (c.kind) {
|
||
.new_cash => {
|
||
const gop = try cash_budget.getOrPut(c.account);
|
||
if (!gop.found_existing) gop.value_ptr.* = 0;
|
||
gop.value_ptr.* += v;
|
||
},
|
||
.cash_delta, .cash_contribution => if (v > 0) {
|
||
const gop = try cash_budget.getOrPut(c.account);
|
||
if (!gop.found_existing) gop.value_ptr.* = 0;
|
||
gop.value_ptr.* += v;
|
||
},
|
||
else => {},
|
||
}
|
||
}
|
||
|
||
// Walk transfer records in caller-supplied order. Caller is
|
||
// responsible for filtering to only those records that should
|
||
// be considered for THIS diff (typically: records new in the
|
||
// after-side `transaction_log.srf` vs. the before-side).
|
||
for (records) |rec| {
|
||
if (rec.type == .in_kind) {
|
||
// 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;
|
||
}
|
||
|
||
switch (rec.dest_lot) {
|
||
.lot => |dl| {
|
||
try matchLotDestination(allocator, changes, &consumed_lot_idx, rec, dl);
|
||
},
|
||
.cash => {
|
||
try matchCashDestination(allocator, changes, &cash_budget, &transfer_funding, rec);
|
||
},
|
||
}
|
||
|
||
tryMatchFromSide(changes, rec);
|
||
}
|
||
|
||
// After every record, so multiple transfers into one account pool their
|
||
// funding before it is drawn against that account's purchases.
|
||
try matchTransferFundedPurchases(allocator, changes, &transfer_funding);
|
||
}
|
||
|
||
/// Append a synthetic `unmatched_transfer` Change carrying the record's
|
||
/// raw amount + from/to + date + a reason string. Detail lives on
|
||
/// `transfer_note`; `amount` encodes into `delta_shares * unit_value`
|
||
/// as `amount * 1.0` so `value()` returns the transfer amount.
|
||
fn appendUnmatched(
|
||
allocator: std.mem.Allocator,
|
||
changes: *std.ArrayList(Change),
|
||
rec: transaction_log.TransferRecord,
|
||
reason: []const u8,
|
||
) !void {
|
||
const from = try allocator.dupe(u8, rec.from);
|
||
const to = try allocator.dupe(u8, rec.to);
|
||
const note = try allocator.dupe(u8, reason);
|
||
try changes.append(allocator, .{
|
||
.kind = .unmatched_transfer,
|
||
.symbol = "",
|
||
.account = to, // "landed on" side for the Flagged display
|
||
.security_type = .cash, // irrelevant - no lot attached
|
||
.delta_shares = rec.amount,
|
||
.unit_value = 1.0,
|
||
.transfer_attributed = rec.amount,
|
||
.transfer_note = note,
|
||
.transfer_from = from,
|
||
.transfer_date = rec.transfer,
|
||
});
|
||
}
|
||
|
||
/// Find the Change matching `dest_lot` and flip its kind. Produces
|
||
/// an `unmatched_transfer` on any mismatch (not found, already
|
||
/// consumed, amount too big).
|
||
fn matchLotDestination(
|
||
allocator: std.mem.Allocator,
|
||
changes: *std.ArrayList(Change),
|
||
consumed: *std.AutoHashMap(usize, void),
|
||
rec: transaction_log.TransferRecord,
|
||
dl: transaction_log.DestLot.LotRef,
|
||
) !void {
|
||
// Find the first matching Change by (account, symbol, open_date)
|
||
// that's:
|
||
// - A destination-side kind (new_* or cash_contribution on the
|
||
// `to` account); transfer_in / partial already consumed
|
||
// counts as "taken".
|
||
// - Not yet claimed by another transfer record.
|
||
//
|
||
// We don't have direct access to the underlying Lot's open_date
|
||
// on the Change struct - it's encoded implicitly in the diff
|
||
// (a `new_stock` Change has one lot on the `after` side with a
|
||
// unique open_date). Since the matcher can't disambiguate
|
||
// multiple lots of the same (account, symbol) opened on
|
||
// different dates, we leave the matching keyed just on
|
||
// (account, symbol) and accept the rare ambiguity. When two
|
||
// lots of the same (account, symbol) appear in the same diff,
|
||
// the user would need separate transfer records per lot - the
|
||
// first record matches the first Change, the second matches the
|
||
// second, etc.
|
||
//
|
||
// TODO: thread open_date through Change. For now, accept the
|
||
// (account, symbol) key and first-match-wins.
|
||
_ = dl.open_date; // consulted only for display (see report printer)
|
||
|
||
var found_idx: ?usize = null;
|
||
var saw_consumed = false;
|
||
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;
|
||
const is_dest_kind = switch (c.kind) {
|
||
.new_stock, .new_drip_lot, .new_cash, .new_cd, .cash_contribution => true,
|
||
else => false,
|
||
};
|
||
if (!is_dest_kind) continue;
|
||
if (consumed.contains(i)) {
|
||
saw_consumed = true;
|
||
continue;
|
||
}
|
||
found_idx = i;
|
||
break;
|
||
}
|
||
|
||
if (found_idx) |i| {
|
||
const c = &changes.items[i];
|
||
const lot_value = c.value();
|
||
if (rec.amount > lot_value + transfer_amount_tolerance) {
|
||
// Amount exceeds lot by more than tolerance: emit
|
||
// unmatched, leave the Change untouched.
|
||
const buf = try std.fmt.allocPrint(
|
||
allocator,
|
||
"amount ${d:.2} exceeds destination lot {s}@ value ${d:.2}",
|
||
.{ rec.amount, dl.symbol, lot_value },
|
||
);
|
||
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
|
||
return;
|
||
}
|
||
|
||
try consumed.put(i, {});
|
||
// Copy the note from the record (if any) onto the Change.
|
||
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);
|
||
|
||
if (@abs(rec.amount - lot_value) <= transfer_amount_tolerance) {
|
||
c.kind = .transfer_in;
|
||
c.transfer_attributed = lot_value; // full
|
||
} else {
|
||
c.kind = .partial_transfer_in;
|
||
c.transfer_attributed = rec.amount; // residual = value() - amount
|
||
}
|
||
c.transfer_note = note_copy;
|
||
c.transfer_from = from_copy;
|
||
c.transfer_date = rec.transfer;
|
||
} else if (saw_consumed) {
|
||
const buf = try std.fmt.allocPrint(
|
||
allocator,
|
||
"destination lot {s}@ on account {s} already claimed by an earlier transfer record",
|
||
.{ dl.symbol, rec.to },
|
||
);
|
||
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
|
||
} else {
|
||
const buf = try std.fmt.allocPrint(
|
||
allocator,
|
||
"destination lot {s}@ not found on account {s}",
|
||
.{ dl.symbol, rec.to },
|
||
);
|
||
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
|
||
}
|
||
}
|
||
|
||
/// Same as `appendUnmatched` but takes a pre-formatted note the
|
||
/// caller already owns (produced via `std.fmt.allocPrint`).
|
||
fn appendUnmatchedWithOwnedNote(
|
||
allocator: std.mem.Allocator,
|
||
changes: *std.ArrayList(Change),
|
||
rec: transaction_log.TransferRecord,
|
||
owned_note: []const u8,
|
||
) !void {
|
||
const from = try allocator.dupe(u8, rec.from);
|
||
const to = try allocator.dupe(u8, rec.to);
|
||
try changes.append(allocator, .{
|
||
.kind = .unmatched_transfer,
|
||
.symbol = "",
|
||
.account = to,
|
||
.security_type = .cash,
|
||
.delta_shares = rec.amount,
|
||
.unit_value = 1.0,
|
||
.transfer_attributed = rec.amount,
|
||
.transfer_note = owned_note,
|
||
.transfer_from = from,
|
||
.transfer_date = rec.transfer,
|
||
});
|
||
}
|
||
|
||
/// Verify the `to` account's cash budget has capacity for this
|
||
/// record, draw from it, and either attach to an existing cash
|
||
/// Change or append a synthetic one.
|
||
///
|
||
/// Attribution is recorded per-Change on `transfer_attributed`, which
|
||
/// `attributedValue()` turns into the unattributed residual. That single
|
||
/// mechanism drives every consumer - the totals math, the contributions
|
||
/// sections, and audit's large-lot filter - so there is no separate
|
||
/// per-account bucket to keep in agreement with it.
|
||
fn matchCashDestination(
|
||
allocator: std.mem.Allocator,
|
||
changes: *std.ArrayList(Change),
|
||
cash_budget: *std.StringHashMap(f64),
|
||
transfer_funding: *std.StringHashMap(FundingShortfall),
|
||
rec: transaction_log.TransferRecord,
|
||
) !void {
|
||
const budget_entry = cash_budget.getPtr(rec.to);
|
||
const available = @max(0.0, if (budget_entry) |p| p.* else 0.0);
|
||
|
||
// Credit whatever cash actually showed up, and carry the rest as a funding
|
||
// budget for the destination's new lots.
|
||
//
|
||
// This used to bail out entirely when the cash increase fell short, which
|
||
// meant a transfer whose cash was invested inside the same window credited
|
||
// nothing at all and every purchase it funded read as new money. The cash
|
||
// is genuinely absent from the snapshot in that case - it arrived and left
|
||
// between two commits - so the shortfall is expected, not a discrepancy.
|
||
// See `matchTransferFundedPurchases`, which draws it down and flags only
|
||
// what the account's new lots cannot absorb.
|
||
const credited = @min(available, rec.amount);
|
||
const shortfall = rec.amount - credited;
|
||
if (shortfall > transfer_amount_tolerance) {
|
||
const gop = try transfer_funding.getOrPut(rec.to);
|
||
if (!gop.found_existing) gop.value_ptr.* = .{ .rec = rec };
|
||
gop.value_ptr.*.amount += shortfall;
|
||
gop.value_ptr.*.declared += rec.amount;
|
||
}
|
||
|
||
// Draw from the budget. Running remainder stays on the budget
|
||
// so later records on the same account see the correct
|
||
// capacity.
|
||
if (budget_entry) |p| p.* -= credited;
|
||
|
||
// Distribute the record amount across the destination account's
|
||
// cash-side Changes by accumulating into each Change's
|
||
// `transfer_attributed`. We deliberately do NOT flip the
|
||
// Change's `kind`: a single cash Change can be drained by
|
||
// multiple records (e.g. two transfers landing on the same
|
||
// cash lot), and a single record can drain across multiple
|
||
// cash Changes (when the user split the inflow into several
|
||
// lots). The kind field can only hold one classification.
|
||
//
|
||
// Bumping `transfer_attributed` makes `attributedValue()`
|
||
// return the unattributed residual - fully-attributed Changes
|
||
// drop out of the "New contributions" section, audit's
|
||
// "Large new lots" filter, and any other consumer that asks
|
||
// "how much of this Change is real new money?". Every consumer -
|
||
// per-account totals, the contributions sections, audit's large-lot
|
||
// filter - reads that same residual, so there is no second view to
|
||
// keep in agreement.
|
||
var remaining = credited;
|
||
for (changes.items) |*c| {
|
||
if (remaining <= 0) break;
|
||
if (!std.mem.eql(u8, c.account, rec.to)) continue;
|
||
const is_cash_kind = switch (c.kind) {
|
||
.new_cash, .cash_contribution => true,
|
||
.cash_delta => c.value() > 0,
|
||
else => false,
|
||
};
|
||
if (!is_cash_kind) continue;
|
||
const unattributed = c.value() - c.transfer_attributed;
|
||
if (unattributed <= 0) continue;
|
||
const draw = @min(unattributed, remaining);
|
||
c.transfer_attributed += draw;
|
||
remaining -= draw;
|
||
}
|
||
// `remaining > 0` here would indicate the budget pre-check
|
||
// accepted a record we couldn't actually distribute. Possible
|
||
// if the cash-side Changes' `value()` totals don't agree with
|
||
// the `cash_budget` we built (they should - both are derived
|
||
// from the same Changes). Leave it as silent over-credit on
|
||
// the per-account bucket; the user-visible symptom would be
|
||
// a small Joint-trust-style residual showing up in audit.
|
||
|
||
// Append a synthetic transfer_in Change for Transfers-section
|
||
// display.
|
||
const from = try allocator.dupe(u8, rec.from);
|
||
const to = try allocator.dupe(u8, rec.to);
|
||
const note_copy: ?[]const u8 = if (rec.note) |n| try allocator.dupe(u8, n) else null;
|
||
try changes.append(allocator, .{
|
||
.kind = .transfer_in,
|
||
.symbol = "",
|
||
.account = to,
|
||
.security_type = .cash,
|
||
.delta_shares = rec.amount,
|
||
.unit_value = 1.0,
|
||
.transfer_attributed = rec.amount,
|
||
.transfer_note = note_copy,
|
||
.transfer_from = from,
|
||
.transfer_date = rec.transfer,
|
||
});
|
||
}
|
||
|
||
/// Best-effort: find the cash that left the `from` account and
|
||
/// reclassify it to `transfer_out`. No-op (silent) if no such Change
|
||
/// exists - the sending side may not be in portfolio.srf at all, which
|
||
/// the format explicitly allows.
|
||
///
|
||
/// Restricted to CASH outflows: a negative `cash_delta`, or a removed /
|
||
/// closed cash lot (a line drained in full). A `type::cash` record says
|
||
/// dollars moved, so a security sale is never its sending leg - selling
|
||
/// to raise the cash is a separate event, and the cash decrease is the
|
||
/// transfer. Securities moving between accounts is what `type::in_kind`
|
||
/// and `matchInKindTransfer` are for, matched by symbol on both sides.
|
||
///
|
||
/// The type gate is load-bearing rather than tidy-up. This scan was
|
||
/// unreachable while `value()` was 0 for every removal; once outflows
|
||
/// carried dollars it went live, and an unrelated sale in the same
|
||
/// account became a candidate. Consuming it here would flip it out of
|
||
/// `lot_removed`, drop it from the sale-proceeds budget in
|
||
/// `matchIntraAccountPurchases`, and make the repurchase it funded read
|
||
/// as new money - reintroducing the bug that budget exists to fix.
|
||
fn tryMatchFromSide(
|
||
changes: *std.ArrayList(Change),
|
||
rec: transaction_log.TransferRecord,
|
||
) void {
|
||
for (changes.items) |*c| {
|
||
if (c.security_type != .cash) continue;
|
||
if (!std.mem.eql(u8, c.account, rec.from)) continue;
|
||
// Must be money leaving. `cash_delta` is signed, so an account
|
||
// that GAINED cash must not be read as a sending leg; removals
|
||
// and closes are outflows by construction.
|
||
const outflow: f64 = switch (c.kind) {
|
||
.cash_delta => if (c.value() < 0) -c.value() else continue,
|
||
.lot_removed, .position_closed => c.face_value,
|
||
else => continue,
|
||
};
|
||
if (outflow < rec.amount - transfer_amount_tolerance) continue;
|
||
// Match: flip to transfer_out. We don't track
|
||
// transfer_attributed on the from side (it's decorative) but we
|
||
// record the counterpart date so the Transfers section can
|
||
// cross-reference.
|
||
c.kind = .transfer_out;
|
||
c.transfer_attributed = rec.amount;
|
||
c.transfer_date = rec.transfer;
|
||
return;
|
||
}
|
||
}
|
||
|
||
/// 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, .position_closed, .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;
|
||
}
|
||
}
|
||
|
||
// ── Intra-account purchase netting ───────────────────────────
|
||
|
||
/// Net same-account internal money movement against the changes that
|
||
/// would otherwise read as fresh contributions.
|
||
///
|
||
/// Two things can fund a purchase without any new money entering the
|
||
/// portfolio, and both show up as changes on the SAME account:
|
||
///
|
||
/// 1. **Cash already in the account.** A plain buy shows 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).
|
||
/// 2. **Proceeds of a security sale.** A reallocation - sell A, buy B
|
||
/// in the same account - shows a valued outflow (`lot_removed`, or
|
||
/// `drip_negative` for a partial sale) and one or more new lots.
|
||
///
|
||
/// Case 2 was previously invisible: the budget only accepted cash, so
|
||
/// a $467k FAGIX -> SPHY/FDVV swap booked the full $467k as a
|
||
/// contribution, and because `compare` derives
|
||
/// `gains = liquid.delta - contributions` the phantom appeared again as
|
||
/// an equal-and-opposite loss.
|
||
///
|
||
/// ## Where the proceeds went decides what they can fund
|
||
///
|
||
/// Sale proceeds cannot fund a purchase if they are demonstrably still
|
||
/// sitting in cash at the end of the window. Splitting on the
|
||
/// account's net cash movement:
|
||
///
|
||
/// ```
|
||
/// proceeds_resting = min(sale_proceeds, max(0, cash_change))
|
||
/// proceeds_spent = sale_proceeds - proceeds_resting
|
||
/// ```
|
||
///
|
||
/// - `proceeds_spent` funds `new_stock` / `new_cd`.
|
||
/// - `proceeds_resting` instead cancels a `cash_contribution` on the
|
||
/// account. That kind only exists on accounts flagged
|
||
/// `cash_is_contribution::true`, where a positive cash delta is
|
||
/// assumed to be new money - an assumption that is wrong for sale
|
||
/// proceeds, and that the flag cannot distinguish on its own.
|
||
///
|
||
/// Without the split, a window holding both a sale whose proceeds
|
||
/// stayed in cash and a genuine deposit that was invested would let
|
||
/// the sale swallow the deposit.
|
||
///
|
||
/// Cash decreases are drawn *after* sale proceeds, so the two cannot
|
||
/// double-count the same dollars: proceeds that passed through cash on
|
||
/// their way into a security are already absorbed, and the drawdown
|
||
/// caps each lot at its remaining unattributed value.
|
||
///
|
||
/// 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 on the destination side stays narrow - only brand-new
|
||
/// `new_stock` / `new_cd` lots and `cash_contribution`.
|
||
/// `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.
|
||
/// Draw `budget` down against the new purchase lots in `account`, marking the
|
||
/// funded portion on each. Returns whatever the account's lots could not
|
||
/// absorb.
|
||
///
|
||
/// Shared by the three things that can fund a purchase without it being new
|
||
/// money: cash that visibly left the same account, the proceeds of a
|
||
/// same-account security sale, and a declared transfer whose cash was spent
|
||
/// before it could be observed. The drawdown is identical; only the meaning of
|
||
/// a leftover differs, which is why the callers handle the return value
|
||
/// differently rather than this function deciding.
|
||
fn drawDownAgainstNewLots(changes: *std.ArrayList(Change), account: []const u8, budget: f64) f64 {
|
||
var remaining = budget;
|
||
for (changes.items) |*c| {
|
||
if (remaining <= 0) break;
|
||
switch (c.kind) {
|
||
.new_stock, .new_cd => {},
|
||
else => continue,
|
||
}
|
||
if (!std.mem.eql(u8, c.account, account)) continue;
|
||
const unattributed = c.attributedValue();
|
||
if (unattributed <= 0) continue;
|
||
const draw = @min(unattributed, remaining);
|
||
c.internal_funded += draw;
|
||
remaining -= draw;
|
||
}
|
||
return remaining;
|
||
}
|
||
|
||
/// Attribute purchases funded by a declared transfer whose cash never appeared
|
||
/// in a snapshot.
|
||
///
|
||
/// A `transfer` record says money moved from A to B. `matchCashDestination`
|
||
/// credits it against an observed cash increase in B - but when the cash is
|
||
/// invested inside the same reconcile window, no snapshot ever contains it: the
|
||
/// diff sees new security lots in B and a few dollars of leftover cash. The
|
||
/// transfer then failed its cash check and the purchases counted as fresh
|
||
/// money, which is how one 401(k)-to-BrokerageLink move reported $738,814 of
|
||
/// contributions that were nothing of the kind.
|
||
///
|
||
/// So the shortfall becomes a funding budget for that account's new lots -
|
||
/// exactly what `matchIntraAccountPurchases` does with an observed cash
|
||
/// decrease, seeded from the operator's declaration instead of from an
|
||
/// observation. Anything the lots cannot absorb is still flagged: a transfer
|
||
/// claiming more than the destination gained is a real discrepancy and must not
|
||
/// be silently swallowed.
|
||
fn matchTransferFundedPurchases(
|
||
allocator: std.mem.Allocator,
|
||
changes: *std.ArrayList(Change),
|
||
funding: *std.StringHashMap(FundingShortfall),
|
||
) !void {
|
||
var it = funding.iterator();
|
||
while (it.next()) |entry| {
|
||
const account = entry.key_ptr.*;
|
||
const sf = entry.value_ptr.*;
|
||
if (sf.amount <= transfer_amount_tolerance) continue;
|
||
const leftover = drawDownAgainstNewLots(changes, account, sf.amount);
|
||
if (leftover <= transfer_amount_tolerance) continue;
|
||
const buf = try std.fmt.allocPrint(
|
||
allocator,
|
||
"transfer of ${d:.2} exceeds the destination's cash increase and new lots by ${d:.2}",
|
||
.{ sf.declared, leftover },
|
||
);
|
||
try appendUnmatchedWithOwnedNote(allocator, changes, sf.rec, buf);
|
||
}
|
||
}
|
||
|
||
/// Per-account transfer shortfall, plus the record it came from so an
|
||
/// unabsorbed remainder can be reported against the right transfer.
|
||
const FundingShortfall = struct {
|
||
amount: f64 = 0,
|
||
declared: f64 = 0,
|
||
rec: transaction_log.TransferRecord,
|
||
};
|
||
|
||
/// Per-account internal-funding flows, gathered in one pass over the
|
||
/// changes so the spent/resting split can be computed before any
|
||
/// drawdown happens.
|
||
const AccountFlows = struct {
|
||
/// Dollars realized in this account by selling securities, or by a
|
||
/// CD paying out (matured or redeemed early, then removed).
|
||
sale_proceeds: f64 = 0,
|
||
/// Signed net movement of the account's cash pool. Positive means
|
||
/// the account is holding more cash at the end of the window.
|
||
cash_change: f64 = 0,
|
||
};
|
||
|
||
fn matchIntraAccountPurchases(
|
||
allocator: std.mem.Allocator,
|
||
changes: *std.ArrayList(Change),
|
||
) !void {
|
||
var flows: std.StringHashMap(AccountFlows) = .init(allocator);
|
||
defer flows.deinit();
|
||
|
||
for (changes.items) |c| {
|
||
const gop = try flows.getOrPut(c.account);
|
||
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||
const f = gop.value_ptr;
|
||
switch (c.kind) {
|
||
// Cash pool moved. `cash_contribution` is a positive
|
||
// cash_delta on a flagged account, so it counts here too -
|
||
// it is still cash arriving.
|
||
.cash_delta, .cash_contribution, .new_cash => f.cash_change += c.value(),
|
||
// A removed lot is a fully-drained line: its dollar amount
|
||
// lives in `face_value` because `value()` is negative for
|
||
// removals. Cash lines drain the pool; securities realize
|
||
// proceeds.
|
||
.lot_removed => if (c.security_type == .cash) {
|
||
f.cash_change -= c.face_value;
|
||
} else {
|
||
f.sale_proceeds += c.face_value;
|
||
},
|
||
// Closed in place, valued at close_price. Cash lines that
|
||
// get closed rather than deleted drain the pool instead.
|
||
.position_closed => if (c.security_type == .cash) {
|
||
f.cash_change -= c.face_value;
|
||
} else {
|
||
f.sale_proceeds += c.face_value;
|
||
},
|
||
// Partial sale of an existing stock lot: shares went down
|
||
// on an unchanged key, so `value()` is the negative
|
||
// proceeds.
|
||
.drip_negative => f.sale_proceeds += -c.value(),
|
||
// A CD that matured or was redeemed, then removed: its face
|
||
// value came back into the account exactly as a sale's
|
||
// proceeds do. Without this, rolling a matured CD into a new
|
||
// one (a ladder) booked the new CD as fresh money, and on a
|
||
// `cash_is_contribution` account the payout landing in cash
|
||
// did too. A CD closed IN PLACE already funds as
|
||
// `position_closed`; this makes deleting it equivalent.
|
||
.cd_matured, .cd_removed_early => f.sale_proceeds += c.face_value,
|
||
else => {},
|
||
}
|
||
}
|
||
|
||
var it = flows.iterator();
|
||
while (it.next()) |e| {
|
||
const account = e.key_ptr.*;
|
||
const f = e.value_ptr.*;
|
||
|
||
// Proceeds still sitting in cash cannot have funded a purchase.
|
||
// Split on the account's net cash movement so a sale whose
|
||
// proceeds stayed put can't swallow a genuine deposit that was
|
||
// invested in the same window.
|
||
const resting = @min(f.sale_proceeds, @max(0, f.cash_change));
|
||
const spent = f.sale_proceeds - resting;
|
||
|
||
// Spent proceeds fund new security lots. A leftover is
|
||
// unremarkable (proceeds can go anywhere) so it is discarded.
|
||
if (spent > 0) _ = drawDownAgainstNewLots(changes, account, spent);
|
||
|
||
// Resting proceeds cancel a `cash_contribution`, which would
|
||
// otherwise book the sale as new money on an account flagged
|
||
// `cash_is_contribution::true`.
|
||
if (resting > 0) drawDownAgainstCashContribution(changes, account, resting);
|
||
|
||
// Finally, cash that visibly left funds whatever new lot value
|
||
// is still unattributed. Drawn last so proceeds that passed
|
||
// through cash on their way into a security aren't counted
|
||
// twice.
|
||
if (f.cash_change < 0) _ = drawDownAgainstNewLots(changes, account, -f.cash_change);
|
||
}
|
||
}
|
||
|
||
/// Draw `budget` down against `cash_contribution` changes in `account`.
|
||
///
|
||
/// Mirrors `drawDownAgainstNewLots` but targets the opt-in
|
||
/// cash-is-contribution kind, whose whole premise - "cash arriving here
|
||
/// is new money" - is false for the proceeds of a security sale. Any
|
||
/// leftover is discarded: the flag exists precisely because cash can
|
||
/// arrive from outside, so an unabsorbed remainder is a real
|
||
/// contribution.
|
||
fn drawDownAgainstCashContribution(changes: *std.ArrayList(Change), account: []const u8, budget: f64) void {
|
||
var remaining = budget;
|
||
for (changes.items) |*c| {
|
||
if (remaining <= 0) break;
|
||
if (c.kind != .cash_contribution) continue;
|
||
if (!std.mem.eql(u8, c.account, account)) continue;
|
||
const unattributed = c.attributedValue();
|
||
if (unattributed <= 0) continue;
|
||
const draw = @min(unattributed, remaining);
|
||
c.internal_funded += draw;
|
||
remaining -= draw;
|
||
}
|
||
}
|
||
|
||
// ── CD payouts as funding (see `matchIntraAccountPurchases`) ──
|
||
|
||
/// A CD lot for the funding tests: $10k face, `open_price` 1.
|
||
fn testCd(symbol: []const u8, account: []const u8, opened: Date, matures: Date) Lot {
|
||
return .{ .symbol = symbol, .shares = 10_000, .open_date = opened, .open_price = 1, .security_type = .cd, .maturity_date = matures, .account = account };
|
||
}
|
||
|
||
test "matchIntraAccountPurchases: a matured CD rolled into a new CD is not new money" {
|
||
// A CD ladder: CD1 matures and is deleted, CD2 is opened with the
|
||
// proceeds, no cash visibly moves. CD2 used to count as a $10k
|
||
// contribution.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(a);
|
||
const before = [_]Lot{testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1))};
|
||
const after = [_]Lot{testCd("CD2", "Sample IRA", Date.fromYmd(2026, 3, 2), Date.fromYmd(2027, 3, 2))};
|
||
const report = try computeReport(a, &before, &after, &prices, Date.fromYmd(2026, 3, 15), .{});
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01);
|
||
}
|
||
|
||
test "matchIntraAccountPurchases: a CD redeemed early and deleted funds a same-account buy" {
|
||
// The deleted-CD twin of the closed-in-place case, which already
|
||
// funded the buy via `position_closed`. Both must agree.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(a);
|
||
const cd = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 6, 1), Date.fromYmd(2027, 6, 1));
|
||
const buy: Lot = .{ .symbol = "VTI", .shares = 40, .open_date = Date.fromYmd(2026, 1, 12), .open_price = 250, .account = "Sample IRA" };
|
||
|
||
const deleted = try computeReport(a, &.{cd}, &.{buy}, &prices, Date.fromYmd(2026, 1, 20), .{});
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(deleted), 0.01);
|
||
|
||
var closed = cd;
|
||
closed.close_date = Date.fromYmd(2026, 1, 10);
|
||
closed.close_price = 1;
|
||
const in_place = try computeReport(a, &.{cd}, &.{ closed, buy }, &prices, Date.fromYmd(2026, 1, 20), .{});
|
||
try std.testing.expectApproxEqAbs(attributionTotalForTest(in_place), attributionTotalForTest(deleted), 0.01);
|
||
}
|
||
|
||
test "matchIntraAccountPurchases: a matured CD's payout parked in cash isn't new money on a cash_is_contribution account" {
|
||
// The payout lands as a positive cash change, which the flag would
|
||
// book as a contribution. The CD's face value offsets it; only the
|
||
// $300 interest is left, because on this account the flag's premise
|
||
// is "cash arriving is new money".
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(a);
|
||
var am = try analysis.parseAccountsFile(a, "#!srfv1\naccount::Sample IRA,tax_type::traditional,cash_is_contribution:bool:true\n");
|
||
const cd = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||
const cash0: Lot = .{ .symbol = "CASH", .shares = 500, .open_date = Date.epoch, .open_price = 1, .security_type = .cash, .account = "Sample IRA" };
|
||
var cash1 = cash0;
|
||
cash1.shares = 10_800;
|
||
const report = try computeReport(a, &.{ cd, cash0 }, &.{cash1}, &prices, Date.fromYmd(2026, 3, 15), .{ .account_map = &am });
|
||
try std.testing.expectApproxEqAbs(@as(f64, 300), attributionTotalForTest(report), 0.01);
|
||
}
|
||
|
||
test "matchIntraAccountPurchases: a CD payout can't absorb an unrelated deposit elsewhere" {
|
||
// Funding is per account. A CD maturing in one account must not
|
||
// cancel a genuine contribution landing in another.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const a = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(a);
|
||
const cd = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||
const buy: Lot = .{ .symbol = "VTI", .shares = 20, .open_date = Date.fromYmd(2026, 3, 10), .open_price = 250, .account = "Sample Brokerage" };
|
||
const report = try computeReport(a, &.{cd}, &.{buy}, &prices, Date.fromYmd(2026, 3, 15), .{});
|
||
try std.testing.expectApproxEqAbs(@as(f64, 5000), attributionTotalForTest(report), 0.01);
|
||
}
|
||
|
||
// ── Output ───────────────────────────────────────────────────
|
||
|
||
fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void {
|
||
const h_color = cli.CLR_HEADER;
|
||
const pos_color = cli.CLR_POSITIVE;
|
||
const mut_color = cli.CLR_MUTED;
|
||
const warn_color = cli.CLR_WARNING;
|
||
|
||
// Header
|
||
try cli.setBold(out, color);
|
||
try cli.printFg(out, color, h_color, "Portfolio contributions report\n", .{});
|
||
try cli.setFg(out, color, mut_color);
|
||
try out.writeAll(" ");
|
||
try out.writeAll(label);
|
||
try out.writeAll("\n\n");
|
||
try cli.reset(out, color);
|
||
|
||
// If nothing changed at all, say so explicitly and return.
|
||
if (report.changes.len == 0) {
|
||
try cli.printFg(out, color, mut_color, " No changes detected.\n", .{});
|
||
return;
|
||
}
|
||
|
||
// ── Section: New contributions / purchases ──
|
||
try printSection(out, "New contributions / purchases", color, h_color);
|
||
var any = false;
|
||
var new_total: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => {
|
||
// 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. 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;
|
||
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
|
||
// (value() - transfer_attributed) in this section, with
|
||
// the full lot value annotated so the user can see where
|
||
// the transferred portion went.
|
||
const residual = c.attributedValue();
|
||
if (residual > 0) {
|
||
any = true;
|
||
new_total += residual;
|
||
try printPartialTransferLine(out, c, color, pos_color, mut_color);
|
||
}
|
||
},
|
||
else => {},
|
||
};
|
||
if (!any) try printNone(out, color, mut_color);
|
||
if (any) try printTotalLine(out, "Total", new_total, color, h_color);
|
||
try out.writeAll("\n");
|
||
|
||
// ── Section: DRIP (confirmed) ──
|
||
try printSection(out, "DRIP (confirmed - lots tagged drip::true)", color, h_color);
|
||
any = false;
|
||
var drip_total: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.new_drip_lot, .drip_confirmed => {
|
||
any = true;
|
||
drip_total += c.value();
|
||
try printChangeLine(out, c, color, pos_color);
|
||
},
|
||
else => {},
|
||
};
|
||
if (!any) try printNone(out, color, mut_color);
|
||
if (any) try printTotalLine(out, "Total", drip_total, color, h_color);
|
||
try out.writeAll("\n");
|
||
|
||
// ── Section: Rollup share deltas (ambiguous) ──
|
||
try printSection(out, "Rollup share deltas (DRIP or contribution)", color, h_color);
|
||
any = false;
|
||
var rollup_total: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.rollup_delta => {
|
||
any = true;
|
||
rollup_total += c.value();
|
||
try printChangeLine(out, c, color, pos_color);
|
||
},
|
||
else => {},
|
||
};
|
||
if (!any) try printNone(out, color, mut_color);
|
||
if (any) try printTotalLine(out, "Total", rollup_total, color, h_color);
|
||
try out.writeAll("\n");
|
||
|
||
// ── Section: CD events ──
|
||
try printSection(out, "CD events", color, h_color);
|
||
any = false;
|
||
var cd_interest_total: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.cd_matured, .cd_removed_early => {
|
||
any = true;
|
||
// Find same-account cash_delta to compute implied interest.
|
||
var matched_cash_delta: ?f64 = null;
|
||
for (report.changes) |c2| {
|
||
if (c2.kind == .cash_delta and std.mem.eql(u8, c2.account, c.account)) {
|
||
matched_cash_delta = c2.value();
|
||
break;
|
||
}
|
||
}
|
||
const interest: ?f64 = if (c.kind == .cd_matured and matched_cash_delta != null)
|
||
matched_cash_delta.? - c.face_value
|
||
else
|
||
null;
|
||
if (interest) |i| if (i > 0) {
|
||
cd_interest_total += i;
|
||
};
|
||
try printCdLine(out, c, interest, color);
|
||
},
|
||
else => {},
|
||
};
|
||
if (!any) try printNone(out, color, mut_color);
|
||
if (any and cd_interest_total > 0) try printTotalLine(out, "Implied interest captured", cd_interest_total, color, h_color);
|
||
try out.writeAll("\n");
|
||
|
||
// ── Section: Cash deltas ──
|
||
//
|
||
// Totalled, unlike the other uncounted sections, because this is the one
|
||
// that usually carries the bulk of `compare`'s "Uncounted in" and the one a
|
||
// reader ends up adding by hand. Safe to total here because these rows print
|
||
// `value()`, the same basis `uncountedTotals` uses - see the note there for
|
||
// why the sale sections are left alone.
|
||
try printSection(out, "Cash deltas (raw balance changes)", color, h_color);
|
||
any = false;
|
||
var cash_delta_total: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.cash_delta => {
|
||
any = true;
|
||
cash_delta_total += c.value();
|
||
try printCashDeltaLine(out, c, report, color);
|
||
},
|
||
else => {},
|
||
};
|
||
if (!any) try printNone(out, color, mut_color);
|
||
if (any) try printTotalLine(out, "Total", cash_delta_total, color, h_color);
|
||
try out.writeAll("\n");
|
||
|
||
// ── Section: Internal purchases (internally funded - not counted) ──
|
||
//
|
||
// Two halves of the same story, kept together so the money can be
|
||
// followed:
|
||
//
|
||
// - Sales that released funds: `position_closed` (closed in
|
||
// place, valued at close_price) and non-cash `lot_removed`
|
||
// (the record was deleted outright). Collapsed by
|
||
// account+symbol, because closing a DRIP-fed position can
|
||
// retire hundreds of lots and one line per lot buries the
|
||
// report - the real trade that prompted this retired 219.
|
||
// - `new_stock` / `new_cd` lots, and `cash_contribution`, whose
|
||
// value was funded (wholly or in part) by that money or by a
|
||
// decrease in the SAME account's cash, as attributed by
|
||
// `matchIntraAccountPurchases`. The funded portion is internal
|
||
// movement, not a fresh contribution, so it shows here (muted)
|
||
// rather than counted in "New contributions". A partially-funded
|
||
// lot also appears there on its unfunded residual.
|
||
var any_internal = false;
|
||
for (report.changes) |c| {
|
||
if (c.internal_funded > 0 or isSaleKind(c)) {
|
||
any_internal = true;
|
||
break;
|
||
}
|
||
}
|
||
if (any_internal) {
|
||
try printSection(out, "Internal purchases (cash / sale proceeds -> securities, not counted)", color, h_color);
|
||
try printCollapsedSales(out, report, color, mut_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
|
||
// destination-side Change (or pooled cash on the receiving
|
||
// account). These entries contribute $0 to attribution; the
|
||
// destination's lot/cash value is reclassified as internal
|
||
// money movement, not new contribution. Shown between Cash
|
||
// deltas and Lot edits so the user can cross-reference them
|
||
// against the raw cash movement above.
|
||
var any_xfer = false;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.transfer_in, .partial_transfer_in, .transfer_out => {
|
||
any_xfer = true;
|
||
break;
|
||
},
|
||
else => {},
|
||
};
|
||
if (any_xfer) {
|
||
try printSection(out, "Transfers (matched - not counted)", color, h_color);
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.transfer_in, .partial_transfer_in, .transfer_out => {
|
||
try printTransferLine(out, c, color, mut_color);
|
||
},
|
||
else => {},
|
||
};
|
||
try out.writeAll("\n");
|
||
}
|
||
|
||
// ── Section: Price-only updates ──
|
||
var any_price = false;
|
||
for (report.changes) |c| if (c.kind == .price_only) {
|
||
any_price = true;
|
||
break;
|
||
};
|
||
if (any_price) {
|
||
try printSection(out, "Price-only updates (informational)", color, h_color);
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.price_only => {
|
||
try printPriceOnlyLine(out, c, color, mut_color);
|
||
},
|
||
else => {},
|
||
};
|
||
try out.writeAll("\n");
|
||
}
|
||
|
||
// ── Section: Lot edits (reclassified, not counted) ──
|
||
//
|
||
// Broken strict lot keys that matched a secondary key with
|
||
// approximately-equal share totals. Shown as a muted section so
|
||
// the user can verify the reclassification was correct (e.g. a CD
|
||
// auto-renewed `open_date`, an account got renamed, or the
|
||
// tax-loss account had shares tweaked during reconciliation).
|
||
// Not counted as contributions, DRIP, or removals.
|
||
var any_edit = false;
|
||
for (report.changes) |c| if (c.kind == .lot_edited) {
|
||
any_edit = true;
|
||
break;
|
||
};
|
||
if (any_edit) {
|
||
try printSection(out, "Lot edits (same position, key rewritten - not counted)", color, h_color);
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.lot_edited => {
|
||
try cli.setFg(out, color, mut_color);
|
||
try writeRowPrefix(out, c.symbol, c.account);
|
||
try out.writeAll(" (strict key broke, shares unchanged)\n");
|
||
try cli.reset(out, color);
|
||
},
|
||
else => {},
|
||
};
|
||
try out.writeAll("\n");
|
||
}
|
||
|
||
// ── Section: Flagged ──
|
||
var any_flag = false;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.flagged, .unmatched_transfer => any_flag = true,
|
||
.lot_removed, .drip_negative => if (!isSaleKind(c)) {
|
||
any_flag = true;
|
||
},
|
||
else => {},
|
||
};
|
||
if (any_flag) {
|
||
try printSection(out, "Flagged for review", color, h_color);
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.flagged => {
|
||
try printFlaggedLine(out, c, color, warn_color);
|
||
},
|
||
// Security sales are reported under Internal purchases,
|
||
// where their proceeds can be read against what they
|
||
// funded. Only a vanished cash line lands here.
|
||
.lot_removed, .drip_negative => if (!isSaleKind(c)) {
|
||
try printFlaggedLine(out, c, color, warn_color);
|
||
},
|
||
.unmatched_transfer => {
|
||
try printUnmatchedTransferLine(out, c, color, warn_color);
|
||
},
|
||
else => {},
|
||
};
|
||
try out.writeAll("\n");
|
||
}
|
||
|
||
// ── Summary ──
|
||
try printSection(out, "Summary by account", color, h_color);
|
||
var ait = report.account_totals.iterator();
|
||
var total_new: f64 = 0;
|
||
var total_drip: f64 = 0;
|
||
var total_rollup: f64 = 0;
|
||
var total_cd_int: f64 = 0;
|
||
// Print per-account rows, recomputing CD interest as we go.
|
||
while (ait.next()) |entry| {
|
||
const acct = entry.key_ptr.*;
|
||
const t = entry.value_ptr.*;
|
||
// Recompute this account's CD interest from change list.
|
||
var cd_int: f64 = 0;
|
||
var face: f64 = 0;
|
||
for (report.changes) |c| {
|
||
if (!std.mem.eql(u8, c.account, acct)) continue;
|
||
if (c.kind == .cd_matured) face += c.face_value;
|
||
}
|
||
if (face > 0 and t.cash_delta > 0) {
|
||
const i = t.cash_delta - face;
|
||
if (i > 0) cd_int = i;
|
||
}
|
||
total_new += t.new_money;
|
||
total_drip += t.drip_confirmed;
|
||
total_rollup += t.rollup;
|
||
total_cd_int += cd_int;
|
||
|
||
const acct_label = if (acct.len == 0) "(no account)" else acct;
|
||
// Same width and the same gutter guarantee as the per-change rows, minus
|
||
// the symbol column this section has no use for.
|
||
try out.writeAll(" ");
|
||
try padTo(out, acct_label, acct_w);
|
||
try printSummaryCell(out, " new", t.new_money, color);
|
||
try printSummaryCell(out, " drip", t.drip_confirmed, color);
|
||
try printSummaryCell(out, " rollup", t.rollup, color);
|
||
try printSummaryCell(out, " cd-int", cd_int, color);
|
||
try out.writeAll("\n");
|
||
}
|
||
try out.writeAll("\n");
|
||
|
||
// Grand totals
|
||
try cli.setBold(out, color);
|
||
try cli.printFg(out, color, h_color, "Totals\n", .{});
|
||
try out.print(" New contributions / purchases: {f}\n", .{Money.from(total_new)});
|
||
try out.print(" DRIP (confirmed): {f}\n", .{Money.from(total_drip)});
|
||
try out.print(" Rollup share deltas: {f} (DRIP or contribution; can't distinguish)\n", .{Money.from(total_rollup)});
|
||
if (total_cd_int > 0) {
|
||
try out.print(" CD interest captured: {f}\n", .{Money.from(total_cd_int)});
|
||
}
|
||
// Grand total across everything "money in"-ish. CD interest is
|
||
// included because it's real return realized during the window,
|
||
// even though it originated inside the portfolio.
|
||
const grand = total_new + total_drip + total_rollup + total_cd_int;
|
||
try cli.printFg(out, color, h_color, " Grand total: {f}\n", .{Money.from(grand)});
|
||
|
||
// The figures `compare` prints as "Uncounted in/out", restated here because
|
||
// that line says "see `zfin contributions`" and until now nothing in this
|
||
// report added up to them. Same helper, so they are the same numbers by
|
||
// construction rather than by hope. Named sections so the rows behind each
|
||
// side can actually be found.
|
||
const unc = uncountedTotals(report.changes);
|
||
if (@abs(unc.in) >= 0.005 or @abs(unc.out) >= 0.005) {
|
||
try out.writeAll("\n");
|
||
try cli.printFg(out, color, h_color, "Uncounted flows (inside `Investment gains`, not attributed)\n", .{});
|
||
try out.print(" In: {f}\n", .{Money.from(unc.in).signed()});
|
||
try out.print(" Out: {f}\n", .{Money.from(unc.out).signed()});
|
||
try cli.printFg(out, color, h_color, " Net: {f}\n", .{Money.from(unc.in + unc.out).signed()});
|
||
|
||
// Name only the sections that actually contributed. Listing all four
|
||
// unconditionally would point at headings that were never printed
|
||
// (Flagged and Lot edits are both conditional), and it would put their
|
||
// titles on screen even when the sections are absent - which is not just
|
||
// untidy, it defeats any "this section did not appear" check.
|
||
var listed: usize = 0;
|
||
for ([_]struct { name: []const u8, hit: bool }{
|
||
.{ .name = "Cash deltas", .hit = anyUncountedIn(report.changes, .cash_delta_group) },
|
||
.{ .name = "Internal purchases", .hit = anyUncountedIn(report.changes, .sale_group) },
|
||
.{ .name = "Flagged for review", .hit = anyUncountedIn(report.changes, .flagged_group) },
|
||
}) |g| {
|
||
if (!g.hit) continue;
|
||
try cli.printFg(out, color, mut_color, "{s}{s}", .{ if (listed == 0) " from: " else ", ", g.name });
|
||
listed += 1;
|
||
}
|
||
if (listed > 0) try out.writeAll("\n");
|
||
}
|
||
}
|
||
|
||
/// Which print section a given uncounted change shows up under. Used only to
|
||
/// caption the total; `Lot edits` is deliberately absent because `lot_edited`
|
||
/// values to zero, so it can never move the figure it would be credited for.
|
||
const UncountedGroup = enum { cash_delta_group, sale_group, flagged_group };
|
||
|
||
fn anyUncountedIn(changes: []const Change, group: UncountedGroup) bool {
|
||
for (changes) |c| {
|
||
if (!isUncountedKind(c.kind)) continue;
|
||
if (@abs(c.value()) < 0.005) continue;
|
||
const g: UncountedGroup = switch (c.kind) {
|
||
.cash_delta => .cash_delta_group,
|
||
.position_closed, .lot_removed, .drip_negative => if (isSaleKind(c)) .sale_group else .flagged_group,
|
||
.flagged => .flagged_group,
|
||
else => continue,
|
||
};
|
||
if (g == group) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
fn printSection(out: *std.Io.Writer, title: []const u8, color: bool, hdr: [3]u8) !void {
|
||
try cli.setBold(out, color);
|
||
try cli.printFg(out, color, hdr, "== {s} ==\n", .{title});
|
||
}
|
||
|
||
fn printNone(out: *std.Io.Writer, color: bool, muted: [3]u8) !void {
|
||
try cli.printFg(out, color, muted, " (none)\n", .{});
|
||
}
|
||
|
||
/// Symbol column. 16 rather than 14, because a portfolio may carry illiquid assets
|
||
/// under free-form names instead of tickers, and 15-character names are ordinary.
|
||
/// The old width was sized for a population this report no longer only contains.
|
||
const sym_w = 16;
|
||
|
||
/// Account column. 30, because a plan account's full name runs to about 28
|
||
/// characters once it carries a plan type and a sub-account qualifier.
|
||
const acct_w = 30;
|
||
|
||
/// Pad `s` into a `w`-wide field, always leaving at least one space behind it.
|
||
///
|
||
/// The guarantee is the point. `{s:<N}` OVERFLOWS instead of truncating, so a
|
||
/// single over-long value both shifted its own row right AND consumed the gutter,
|
||
/// welding itself to the next field. And no fixed `N` avoids it here: symbols in
|
||
/// this report run from a 3-character ticker to a ~24-character OCC option
|
||
/// description, with free-form illiquid asset names in between. So the widths above
|
||
/// are chosen for the common case and this keeps the rare long one readable - it
|
||
/// loses its alignment, not its whitespace.
|
||
fn padTo(out: *std.Io.Writer, s: []const u8, w: usize) !void {
|
||
try out.writeAll(s);
|
||
try out.splatByteAll(' ', if (s.len >= w) 1 else w - s.len);
|
||
}
|
||
|
||
/// The ` symbol account ` prefix every per-change row opens with.
|
||
///
|
||
/// One function rather than a format-string literal repeated at fourteen call
|
||
/// sites. The literal is how the widths drifted out of step in the first place:
|
||
/// twelve rows said `{s:<14}{s:<24}`, the CD continuation said the same with
|
||
/// blank arguments, the lot-edit row said `{s: <12} {s: <24}`, and the account
|
||
/// summary said `{s:<28}` - four different answers to one question, and each new
|
||
/// long value found a different one of them.
|
||
///
|
||
/// Callers own the single space that follows, so content lands one column past
|
||
/// `acct_w`. Keep it: every section has to agree on that column or the report
|
||
/// shears between sections instead of within a row, which is harder to spot.
|
||
fn writeRowPrefix(out: *std.Io.Writer, symbol: []const u8, account: []const u8) !void {
|
||
try out.writeAll(" ");
|
||
try padTo(out, symbol, sym_w);
|
||
try padTo(out, account, acct_w);
|
||
}
|
||
|
||
test "padTo: pads short values to width and never welds a long one" {
|
||
var buf: [128]u8 = undefined;
|
||
|
||
// Short: padded to the column.
|
||
var w1 = std.Io.Writer.fixed(&buf);
|
||
try padTo(&w1, "CASH", 16);
|
||
try std.testing.expectEqualStrings("CASH ", w1.buffered());
|
||
|
||
// Exactly one under the width: still a gutter, and the last length that aligns.
|
||
var w2 = std.Io.Writer.fixed(&buf);
|
||
try padTo(&w2, "Fifteen Chars15", 16);
|
||
try std.testing.expectEqualStrings("Fifteen Chars15 ", w2.buffered());
|
||
|
||
// Over: alignment is gone, whitespace is not. `{s:<16}` produced no space
|
||
// here at all, which ran the value into the next column. An OCC option
|
||
// description is the realistic overflow.
|
||
var w3 = std.Io.Writer.fixed(&buf);
|
||
try padTo(&w3, "ZZZZ 01/01/2030 100.00 C", 16);
|
||
try std.testing.expectEqualStrings("ZZZZ 01/01/2030 100.00 C ", w3.buffered());
|
||
}
|
||
|
||
fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void {
|
||
try cli.printFg(out, color, hdr, " {s}: {f}\n", .{ label, Money.from(v) });
|
||
}
|
||
|
||
fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !void {
|
||
var share_buf: [32]u8 = undefined;
|
||
var price_buf: [32]u8 = undefined;
|
||
var val_buf: [32]u8 = undefined;
|
||
const share_str = std.fmt.bufPrint(&share_buf, "{d:.4}", .{c.delta_shares}) catch "?";
|
||
const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(c.unit_value)}) catch "$?";
|
||
const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.value())}) catch "$?";
|
||
|
||
const acct = if (c.account.len == 0) "(no account)" else c.account;
|
||
try writeRowPrefix(out, c.symbol, acct);
|
||
if (c.security_type == .cash) {
|
||
try cli.printFg(out, color, pos, " {s}", .{val_str});
|
||
} else {
|
||
try out.print(" {s} shares × {s} = ", .{ share_str, price_str });
|
||
try cli.printFg(out, color, pos, "{s}", .{val_str});
|
||
}
|
||
try out.writeAll("\n");
|
||
}
|
||
|
||
fn printCdLine(out: *std.Io.Writer, c: Change, implied_interest: ?f64, color: bool) !void {
|
||
var mat_buf: [10]u8 = undefined;
|
||
const mat_str = if (c.maturity_date) |d| (std.fmt.bufPrint(&mat_buf, "{f}", .{d}) catch "????-??-??") else "(no maturity)";
|
||
const acct = if (c.account.len == 0) "(no account)" else c.account;
|
||
const verb = switch (c.kind) {
|
||
.cd_matured => "matured",
|
||
.cd_removed_early => "removed EARLY",
|
||
else => "removed",
|
||
};
|
||
try writeRowPrefix(out, c.symbol, acct);
|
||
try out.print(" {s:<16} face {f} maturity {s}\n", .{
|
||
verb,
|
||
Money.from(c.face_value),
|
||
mat_str,
|
||
});
|
||
if (implied_interest) |i| {
|
||
try writeRowPrefix(out, "", "");
|
||
try cli.printFg(out, color, cli.CLR_POSITIVE, " implied interest: {f}\n", .{Money.from(i)});
|
||
}
|
||
}
|
||
|
||
fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, color: bool) !void {
|
||
const v = c.value();
|
||
const acct = if (c.account.len == 0) "(no account)" else c.account;
|
||
const sign = if (v >= 0) "+" else "-";
|
||
try writeRowPrefix(out, c.symbol, acct);
|
||
try out.writeAll(" cash ");
|
||
try cli.printGainLoss(out, color, v, "{s}{f}", .{ sign, Money.from(@abs(v)) });
|
||
|
||
// Hint if a CD matured in the same account.
|
||
for (report.changes) |o| {
|
||
if (o.kind == .cd_matured and std.mem.eql(u8, o.account, c.account)) {
|
||
try cli.printFg(out, color, cli.CLR_MUTED, " (may include CD maturity of {f})", .{Money.from(o.face_value)});
|
||
break;
|
||
}
|
||
}
|
||
try out.writeAll("\n");
|
||
}
|
||
|
||
fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void {
|
||
const acct = if (c.account.len == 0) "(no account)" else c.account;
|
||
try cli.setFg(out, color, muted);
|
||
try writeRowPrefix(out, c.symbol, acct);
|
||
try out.print(" price {f} -> {f}\n", .{
|
||
Money.from(c.old_price),
|
||
Money.from(c.new_price),
|
||
});
|
||
}
|
||
|
||
fn printFlaggedLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !void {
|
||
const acct = if (c.account.len == 0) "(no account)" else c.account;
|
||
try cli.setFg(out, color, warn);
|
||
switch (c.kind) {
|
||
.flagged => {
|
||
try writeRowPrefix(out, c.symbol, acct);
|
||
try out.print(" {s}", .{c.detail orelse "edited"});
|
||
},
|
||
.lot_removed => {
|
||
try writeRowPrefix(out, c.symbol, acct);
|
||
try out.print(" {s} lot removed (face {f})", .{
|
||
@tagName(c.security_type), Money.from(c.face_value),
|
||
});
|
||
},
|
||
.drip_negative => {
|
||
try writeRowPrefix(out, c.symbol, acct);
|
||
try out.print(" shares decreased on existing lot ({f})", .{Money.from(@abs(c.value()))});
|
||
},
|
||
else => {},
|
||
}
|
||
try cli.reset(out, color);
|
||
try out.writeAll("\n");
|
||
}
|
||
|
||
fn printSummaryCell(out: *std.Io.Writer, label: []const u8, v: f64, color: bool) !void {
|
||
try out.print("{s} ", .{label});
|
||
if (v == 0) {
|
||
try cli.printFg(out, color, cli.CLR_MUTED, "{s:>12}", .{"-"});
|
||
} else {
|
||
try cli.printFg(out, color, cli.CLR_POSITIVE, "{f}", .{Money.from(v).padRight(12)});
|
||
}
|
||
}
|
||
|
||
// ── Transfer-related line printers ───────────────────────────
|
||
|
||
/// Two-line rendering for a matched transfer (either a destination
|
||
/// lot/cash match or a from-side match). Muted throughout - these
|
||
/// don't count toward attribution so visually step them back.
|
||
///
|
||
/// ```
|
||
/// 2026-05-02 $145,300.00 Acct A -> Acct B (full attribution)
|
||
/// -> SYM@2026-05-03
|
||
/// ```
|
||
fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void {
|
||
var date_buf: [10]u8 = undefined;
|
||
const date_str = if (c.transfer_date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??";
|
||
var val_buf: [32]u8 = undefined;
|
||
const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?";
|
||
|
||
const from_str = c.transfer_from orelse "?";
|
||
// For transfer_in / partial on the destination side, c.account
|
||
// is the `to` account. For transfer_out, c.account is the `from`
|
||
// account (the sending Change). Label accordingly.
|
||
const arrow_from = if (c.kind == .transfer_out) c.account else from_str;
|
||
const arrow_to = if (c.kind == .transfer_out)
|
||
"?" // from-side match has no explicit `to` carried through
|
||
else
|
||
c.account;
|
||
|
||
const tag: []const u8 = switch (c.kind) {
|
||
.transfer_in => "(full attribution)",
|
||
.partial_transfer_in => "(partial attribution)",
|
||
.transfer_out => "(from side)",
|
||
else => "",
|
||
};
|
||
|
||
try cli.printFg(
|
||
out,
|
||
color,
|
||
muted,
|
||
" {s} {s} {s} -> {s} {s}\n",
|
||
.{ date_str, val_str, arrow_from, arrow_to, tag },
|
||
);
|
||
|
||
// Second line: destination detail. For lot destinations, show
|
||
// the SYM@DATE. For cash, show "-> cash". For partial, show the
|
||
// lot_value / attributed breakdown.
|
||
if (c.kind == .partial_transfer_in) {
|
||
const lot_value = c.value();
|
||
const residual = lot_value - c.transfer_attributed;
|
||
try cli.printFg(
|
||
out,
|
||
color,
|
||
muted,
|
||
" -> {s} ({f} of {f} lot - {f} from pre-existing cash)\n",
|
||
.{
|
||
if (c.symbol.len > 0) c.symbol else "cash",
|
||
Money.from(c.transfer_attributed),
|
||
Money.from(lot_value),
|
||
Money.from(residual),
|
||
},
|
||
);
|
||
} else if (c.symbol.len > 0) {
|
||
try cli.printFg(out, color, muted, " -> {s}\n", .{c.symbol});
|
||
} else {
|
||
try cli.printFg(out, color, muted, " -> cash\n", .{});
|
||
}
|
||
|
||
// Optional note from the record.
|
||
if (c.transfer_note) |n| {
|
||
if (n.len > 0) {
|
||
try cli.printFg(out, color, muted, " ({s})\n", .{n});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Single-line rendering for an unmatched transfer record, shown
|
||
/// in the Flagged section. Keeps the layout similar to
|
||
/// `printFlaggedLine` for visual consistency.
|
||
fn printUnmatchedTransferLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !void {
|
||
var date_buf: [10]u8 = undefined;
|
||
const date_str = if (c.transfer_date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??";
|
||
var val_buf: [32]u8 = undefined;
|
||
const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?";
|
||
const from_str = c.transfer_from orelse "?";
|
||
|
||
try cli.setFg(out, color, warn);
|
||
try out.print(" ? Transfer {s} {s} {s} -> {s}\n", .{ date_str, val_str, from_str, c.account });
|
||
if (c.transfer_note) |n| {
|
||
try out.print(" {s}\n", .{n});
|
||
}
|
||
try cli.reset(out, color);
|
||
}
|
||
|
||
/// Render a `partial_transfer_in` row in the "New contributions"
|
||
/// section. Shows the residual value (the portion that wasn't
|
||
/// attributable to the transfer) plus an annotation explaining the
|
||
/// split so the user can tell at a glance why the number is smaller
|
||
/// than the lot's face value.
|
||
fn printPartialTransferLine(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 writeRowPrefix(out, sym, acct);
|
||
try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)});
|
||
try cli.printFg(
|
||
out,
|
||
color,
|
||
muted,
|
||
" (of {f} total - rest from transfer)\n",
|
||
.{Money.from(lot_value)},
|
||
);
|
||
}
|
||
|
||
/// 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 writeRowPrefix(out, 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) },
|
||
);
|
||
}
|
||
|
||
/// Is this Change a security sale, i.e. something that released funds
|
||
/// into its account? Cash lines that drain are movement within the
|
||
/// cash pool, not a sale, so they are excluded.
|
||
fn isSaleKind(c: Change) bool {
|
||
return switch (c.kind) {
|
||
.position_closed, .lot_removed => c.security_type != .cash,
|
||
.drip_negative => true,
|
||
else => false,
|
||
};
|
||
}
|
||
|
||
/// Print the sales that released funds, one line per (account, symbol)
|
||
/// rather than one per lot.
|
||
///
|
||
/// Collapsing is not cosmetic. A DRIP-fed position accumulates a lot
|
||
/// per distribution, so closing one can retire hundreds of records -
|
||
/// the trade that motivated this work retired 219, which as individual
|
||
/// lines swamped every other section in the report. The lot count is
|
||
/// kept in the output so nothing is hidden, just summarized.
|
||
fn printCollapsedSales(out: *std.Io.Writer, report: *const Report, color: bool, muted: [3]u8) !void {
|
||
for (report.changes, 0..) |c, i| {
|
||
if (!isSaleKind(c)) continue;
|
||
|
||
// Print each group once, at its first member. Quadratic, but
|
||
// bounded by the sale count in a single window and it keeps
|
||
// this printer allocation-free like every other one here.
|
||
var already_printed = false;
|
||
for (report.changes[0..i]) |o| {
|
||
if (!isSaleKind(o)) continue;
|
||
if (std.mem.eql(u8, o.account, c.account) and std.mem.eql(u8, o.symbol, c.symbol)) {
|
||
already_printed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (already_printed) continue;
|
||
|
||
var proceeds: f64 = 0;
|
||
var lots: usize = 0;
|
||
var closed_in_place = false;
|
||
for (report.changes) |o| {
|
||
if (!isSaleKind(o)) continue;
|
||
if (!std.mem.eql(u8, o.account, c.account)) continue;
|
||
if (!std.mem.eql(u8, o.symbol, c.symbol)) continue;
|
||
proceeds += if (o.kind == .drip_negative) @abs(o.value()) else o.face_value;
|
||
lots += 1;
|
||
if (o.kind == .position_closed) closed_in_place = true;
|
||
}
|
||
|
||
const acct = if (c.account.len == 0) "(no account)" else c.account;
|
||
try cli.setFg(out, color, muted);
|
||
// `close_price` is what the sale realized; anything else is a
|
||
// current-price proxy, so say which one the reader is looking at.
|
||
const basis: []const u8 = if (closed_in_place) "at close" else "at mark";
|
||
if (lots == 1) {
|
||
try writeRowPrefix(out, c.symbol, acct);
|
||
try out.print(" sold {s} ({f})", .{ basis, Money.from(proceeds) });
|
||
} else {
|
||
try writeRowPrefix(out, c.symbol, acct);
|
||
try out.print(" sold {d} lots {s} ({f})", .{ lots, basis, Money.from(proceeds) });
|
||
}
|
||
try cli.reset(out, color);
|
||
try out.writeAll("\n");
|
||
}
|
||
}
|
||
|
||
/// 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 writeRowPrefix(out, sym, acct);
|
||
try out.print(" {f} from existing cash", .{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;
|
||
|
||
fn parseArgsForTest(today: zfin.Date, args: []const []const u8) !ParsedArgs {
|
||
var ctx: framework.RunCtx = .{
|
||
.io = std.testing.io,
|
||
.allocator = std.testing.allocator,
|
||
.gpa = std.testing.allocator,
|
||
// SAFETY: parseArgs doesn't touch environ_map.
|
||
.environ_map = undefined,
|
||
.config = .{ .cache_dir = "" },
|
||
.svc = null,
|
||
.globals = .{},
|
||
.today = today,
|
||
.now_s = 0,
|
||
.color = false,
|
||
// SAFETY: parseArgs doesn't write to out.
|
||
.out = undefined,
|
||
};
|
||
return parseArgs(&ctx, args);
|
||
}
|
||
|
||
test "parseArgs: empty args produces both null" {
|
||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||
const parsed = try parseArgsForTest(today, &.{});
|
||
try testing.expect(parsed.before == null);
|
||
try testing.expect(parsed.after == null);
|
||
}
|
||
|
||
test "parseArgs: --since populates before as snapshot_add" {
|
||
// `.snapshot_add`, not `.date_at_or_before`. This is what makes a date resolve
|
||
// the same way `compare` resolves one, so the two commands report the same
|
||
// money for the same requested week. It degrades to the old date behaviour
|
||
// inside `git.resolveSpec` when no snapshot exists, so nothing is lost.
|
||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||
const args = [_][]const u8{ "--since", "2026-04-01" };
|
||
const parsed = try parseArgsForTest(today, &args);
|
||
switch (parsed.before.?) {
|
||
.snapshot_add => |d| try testing.expect(d.eql(zfin.Date.fromYmd(2026, 4, 1))),
|
||
else => try testing.expect(false),
|
||
}
|
||
}
|
||
|
||
test "parseArgs: --until populates after as snapshot_add too" {
|
||
// Both endpoints, because both can be wrong and they fail in opposite
|
||
// directions - a before anchor that is too early re-counts a window, an after
|
||
// anchor that is too early drops it.
|
||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||
const args = [_][]const u8{ "--since", "2026-04-01", "--until", "2026-04-15" };
|
||
const parsed = try parseArgsForTest(today, &args);
|
||
switch (parsed.after.?) {
|
||
.snapshot_add => |d| try testing.expect(d.eql(zfin.Date.fromYmd(2026, 4, 15))),
|
||
else => try testing.expect(false),
|
||
}
|
||
}
|
||
|
||
test "specDate: reads the date out of either date-carrying variant" {
|
||
// The ordering check used to test for `.date_at_or_before` by hand and went
|
||
// blind the moment a date started producing `.snapshot_add`. Routing both
|
||
// through one accessor is what stops that recurring.
|
||
const d = zfin.Date.fromYmd(2026, 4, 1);
|
||
try testing.expect(specDate(.{ .date_at_or_before = d }).?.eql(d));
|
||
try testing.expect(specDate(.{ .snapshot_add = d }).?.eql(d));
|
||
try testing.expect(specDate(.{ .git_ref = "HEAD" }) == null);
|
||
try testing.expect(specDate(.working_copy) == null);
|
||
}
|
||
|
||
test "parseArgs: an inverted window is still rejected after the spec change" {
|
||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||
const args = [_][]const u8{ "--since", "2026-04-15", "--until", "2026-04-01" };
|
||
try testing.expectError(error.InvalidArg, parseArgsForTest(today, &args));
|
||
}
|
||
|
||
test "parseArgs: --since + --until populates both" {
|
||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||
const args = [_][]const u8{ "--since", "2026-04-01", "--until", "2026-05-01" };
|
||
const parsed = try parseArgsForTest(today, &args);
|
||
try testing.expect(parsed.before != null);
|
||
try testing.expect(parsed.after != null);
|
||
}
|
||
|
||
test "parseArgs: --commit-after working" {
|
||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||
const args = [_][]const u8{ "--commit-after", "working" };
|
||
const parsed = try parseArgsForTest(today, &args);
|
||
try testing.expect(parsed.after.? == .working_copy);
|
||
}
|
||
|
||
test "parseArgs: --since + --commit-before is duplicate axis" {
|
||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||
const args = [_][]const u8{ "--since", "1W", "--commit-before", "HEAD" };
|
||
try testing.expectError(error.DuplicateEndpoint, parseArgsForTest(today, &args));
|
||
}
|
||
|
||
test "parseArgs: --since after --until errors" {
|
||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||
const args = [_][]const u8{ "--since", "2026-05-01", "--until", "2026-04-01" };
|
||
try testing.expectError(error.InvalidArg, parseArgsForTest(today, &args));
|
||
}
|
||
|
||
test "parseArgs: unknown flag errors" {
|
||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||
const args = [_][]const u8{ "--bogus", "value" };
|
||
try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args));
|
||
}
|
||
|
||
test "computeReport: fresh stock purchase counts as new 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();
|
||
try prices.put("AAPL", 200.0);
|
||
|
||
const before = [_]Lot{};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 180, .account = "Brokerage" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1800.0), report.changes[0].value(), 0.01);
|
||
}
|
||
|
||
test "computeReport: rollup_delta when shares increase on untagged lot" {
|
||
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("VBTLX", 9.79);
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 9.79, .account = "DCP" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "VBTLX", .shares = 1010, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 9.79, .account = "DCP" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
// drip::false on both sides -> rollup_delta (ambiguous: DRIP or contribution)
|
||
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 10.0), report.changes[0].delta_shares, 0.001);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 97.9), report.changes[0].value(), 0.01);
|
||
}
|
||
|
||
test "computeReport: matured CD with maturity_date <= today" {
|
||
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 = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Sample IRA", .maturity_date = Date.fromYmd(2026, 4, 17) },
|
||
};
|
||
const after = [_]Lot{};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.cd_matured, report.changes[0].kind);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 58000.0), report.changes[0].face_value, 0.01);
|
||
}
|
||
|
||
test "computeReport: CD removed before maturity flagged as early" {
|
||
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 = "CD2", .shares = 50000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Brokerage", .maturity_date = Date.fromYmd(2027, 1, 1) },
|
||
};
|
||
const after = [_]Lot{};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.cd_removed_early, report.changes[0].kind);
|
||
}
|
||
|
||
test "computeReport: price-only update not classified as cash flow" {
|
||
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 = "NON40OR52", .shares = 5000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9), .account = "401k" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "NON40OR52", .shares = 5000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18), .account = "401k" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.price_only, report.changes[0].kind);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 161.71), report.changes[0].old_price, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 169.07), report.changes[0].new_price, 0.01);
|
||
}
|
||
|
||
test "computeReport: CD matured + cash increase -> implied interest available" {
|
||
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();
|
||
|
||
// Before: CD $58k + cash $3k. After: no CD, cash $62.5k.
|
||
const before = [_]Lot{
|
||
.{ .symbol = "CDX", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2026, 4, 17) },
|
||
.{ .symbol = "CASH", .shares = 3024.66, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "CASH", .shares = 62510.95, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
// Expect: 1 cd_matured, 1 cash_delta
|
||
var n_matured: usize = 0;
|
||
var n_cash: usize = 0;
|
||
var cash_delta: f64 = 0;
|
||
var face: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.cd_matured => {
|
||
n_matured += 1;
|
||
face += c.face_value;
|
||
},
|
||
.cash_delta => {
|
||
n_cash += 1;
|
||
cash_delta += c.value();
|
||
},
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 1), n_matured);
|
||
try std.testing.expectEqual(@as(usize, 1), n_cash);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 58000.0), face, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 62510.95 - 3024.66), cash_delta, 0.01);
|
||
// Implied interest: cash_delta - face = 59486.29 - 58000 = 1486.29
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1486.29), cash_delta - face, 0.01);
|
||
}
|
||
|
||
test "computeReport: unit_value prefers current price over open_price for rollup delta" {
|
||
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("SPY", 461.24);
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "SPY", .shares = 717.34, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.24, .account = "Tax Loss" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "SPY", .shares = 718.4848, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.24, .account = "Tax Loss" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
|
||
// 1.1448 * 461.24 ≈ 528.07
|
||
try std.testing.expectApproxEqAbs(@as(f64, 528.07), report.changes[0].value(), 0.1);
|
||
}
|
||
|
||
test "computeReport: manual-priced lot (price:: no ticker) uses manual price" {
|
||
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();
|
||
// No price in the map - should fall back to manual price::.
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "NON40OR52", .shares = 5070.866, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .account = "401k" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "NON40OR52", .shares = 5075.077, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .account = "401k" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
|
||
// (5075.077 - 5070.866) = 4.211 shares × 169.07 = 711.96
|
||
try std.testing.expectApproxEqAbs(@as(f64, 711.96), report.changes[0].value(), 0.5);
|
||
}
|
||
|
||
// ── price_ratio regression tests ─────────────────────────────
|
||
//
|
||
// `lot.open_price` and `lot.price` (manual override) are both in the
|
||
// LOT's own share-class terms - i.e. preadjusted. Multiplying either
|
||
// by `lot.price_ratio` would double-apply the ratio. Only API-fetched
|
||
// prices from `prices.get(...)` (retail share class) need the ratio.
|
||
// See the "Pricing model" doc-block at the top of `models/portfolio.zig`.
|
||
//
|
||
// These four tests cover each of the three unit_value branches in
|
||
// `computeReport`'s new-lot and same-key-share-delta paths. Without
|
||
// the fix, the new-lot, manual-price, and open-price fallback tests
|
||
// inflate value by `price_ratio`× while the live-price test stays
|
||
// correct.
|
||
|
||
test "computeReport: new institutional stock lot uses preadjusted open_price (no double-ratio)" {
|
||
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();
|
||
// No live price - exercises the open_price fallback branch in
|
||
// the new-lot path. open_price is in the LOT's institutional
|
||
// share class, so the ratio must NOT be applied.
|
||
|
||
const before = [_]Lot{};
|
||
const after = [_]Lot{
|
||
.{
|
||
.symbol = "02315N402",
|
||
.ticker = "VTTVX",
|
||
.shares = 39.249,
|
||
.open_date = Date.fromYmd(2026, 2, 26),
|
||
.open_price = 140.92,
|
||
.price_ratio = 6.6139,
|
||
.account = "Sample 401(k)",
|
||
},
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
|
||
// 39.249 × 140.92 = 5,531.18 (institutional value).
|
||
// Buggy version produces 39.249 × 140.92 × 6.6139 ≈ 36,581.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 5531.18), report.changes[0].value(), 0.5);
|
||
}
|
||
|
||
test "computeReport: same-key share delta with no live price uses preadjusted open_price" {
|
||
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();
|
||
// No price in the map - open_price fallback should fire.
|
||
|
||
const before = [_]Lot{
|
||
.{
|
||
.symbol = "02315N402",
|
||
.ticker = "VTTVX",
|
||
.shares = 100,
|
||
.open_date = Date.fromYmd(2026, 2, 26),
|
||
.open_price = 140.92,
|
||
.price_ratio = 6.6139,
|
||
.account = "Sample 401(k)",
|
||
},
|
||
};
|
||
const after = [_]Lot{
|
||
.{
|
||
.symbol = "02315N402",
|
||
.ticker = "VTTVX",
|
||
.shares = 110,
|
||
.open_date = Date.fromYmd(2026, 2, 26),
|
||
.open_price = 140.92,
|
||
.price_ratio = 6.6139,
|
||
.account = "Sample 401(k)",
|
||
},
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
|
||
// 10 × 140.92 = 1,409.20 (institutional value of the share delta).
|
||
// Buggy version produces 10 × 140.92 × 6.6139 ≈ 9,322.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1409.20), report.changes[0].value(), 0.5);
|
||
}
|
||
|
||
test "computeReport: same-key share delta with manual price:: uses preadjusted manual price" {
|
||
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();
|
||
// No price in the map - manual `price::` override should fire.
|
||
// Manual prices are entered in the LOT's share class (what the
|
||
// user sees on their statement), so they're preadjusted.
|
||
|
||
const before = [_]Lot{
|
||
.{
|
||
.symbol = "02315N402",
|
||
.ticker = "VTTVX",
|
||
.shares = 100,
|
||
.open_date = Date.fromYmd(2026, 2, 26),
|
||
.open_price = 140.92,
|
||
.price = 145.00,
|
||
.price_ratio = 6.6139,
|
||
.account = "Sample 401(k)",
|
||
},
|
||
};
|
||
const after = [_]Lot{
|
||
.{
|
||
.symbol = "02315N402",
|
||
.ticker = "VTTVX",
|
||
.shares = 110,
|
||
.open_date = Date.fromYmd(2026, 2, 26),
|
||
.open_price = 140.92,
|
||
.price = 145.00,
|
||
.price_ratio = 6.6139,
|
||
.account = "Sample 401(k)",
|
||
},
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
|
||
// 10 × 145.00 = 1,450.00. Buggy version: 10 × 145.00 × 6.6139 ≈ 9,590.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1450.00), report.changes[0].value(), 0.5);
|
||
}
|
||
|
||
test "computeReport: same-key share delta with live price applies ratio" {
|
||
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();
|
||
// Live retail-class price - ratio MUST be applied to convert
|
||
// to institutional NAV. This is the only branch that's correct
|
||
// in the buggy code; lock it in so the fix doesn't regress.
|
||
try prices.put("VTTVX", 21.30);
|
||
|
||
const before = [_]Lot{
|
||
.{
|
||
.symbol = "02315N402",
|
||
.ticker = "VTTVX",
|
||
.shares = 100,
|
||
.open_date = Date.fromYmd(2026, 2, 26),
|
||
.open_price = 140.92,
|
||
.price_ratio = 6.6139,
|
||
.account = "Sample 401(k)",
|
||
},
|
||
};
|
||
const after = [_]Lot{
|
||
.{
|
||
.symbol = "02315N402",
|
||
.ticker = "VTTVX",
|
||
.shares = 110,
|
||
.open_date = Date.fromYmd(2026, 2, 26),
|
||
.open_price = 140.92,
|
||
.price_ratio = 6.6139,
|
||
.account = "Sample 401(k)",
|
||
},
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
|
||
// 10 × 21.30 × 6.6139 = 1,408.76 (institutional value).
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1408.76), report.changes[0].value(), 0.5);
|
||
}
|
||
|
||
test "computeReport: maturity_date change on same CD 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();
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "CDY", .shares = 87000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2026, 7, 15) },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "CDY", .shares = 87000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 7, 15) },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.flagged, report.changes[0].kind);
|
||
}
|
||
|
||
test "computeReport: CD open_date rewrite reclassified as edit, not new+removed" {
|
||
// The classic CD auto-renewal scenario: shares and account stay
|
||
// the same, but the `open_date` gets rewritten to the renewal
|
||
// date. Prior behavior was a phantom $58k new_cd contribution
|
||
// plus a $58k cd_removed_early. After edit detection: one
|
||
// lot_edited, no 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 = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 2, 25) },
|
||
};
|
||
const after = [_]Lot{
|
||
// Same CD, rewritten open_date (e.g. renewal) - key broken.
|
||
.{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 4, 20), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 4, 20) },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);
|
||
|
||
// Attribution must see zero contribution for this CD.
|
||
var new_contrib: f64 = 0;
|
||
var drip: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.new_stock, .new_cash, .new_cd, .new_option => new_contrib += c.value(),
|
||
.new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
|
||
else => {},
|
||
};
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), new_contrib, 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Sum of everything a window reports as new money. Mirrors
|
||
/// `summarizeAttribution`'s buckets without needing a ReportContext, so
|
||
/// classifier-level tests can assert on the same figure the CLI prints.
|
||
fn attributionTotalForTest(report: Report) f64 {
|
||
var total: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution, .partial_transfer_in => total += c.attributedValue(),
|
||
.new_drip_lot, .drip_confirmed, .rollup_delta => total += c.value(),
|
||
else => {},
|
||
};
|
||
return total;
|
||
}
|
||
|
||
// ── Security-sale funding (intra-account reallocation) ───────
|
||
//
|
||
// Case labels A-F below match the scenario table worked out when this
|
||
// was designed; each is a distinct shape of "did money actually enter
|
||
// the portfolio". They are kept together so the whole matrix is
|
||
// visible at once - the bug these fix was caused by reasoning about
|
||
// one shape (a sale in the same window as the rebuy) without checking
|
||
// the others.
|
||
|
||
test "computeReport: case A - intra-account reallocation is not a contribution" {
|
||
// The reported bug, reduced. A bond fund is sold and the proceeds
|
||
// are immediately redeployed into two ETFs in the SAME account.
|
||
// Nothing entered the portfolio, so contributions must be $0.
|
||
//
|
||
// Shapes match the real trade: 41,285.219 sh of the sold fund at
|
||
// $11.32 = $467,348.68 of proceeds, redeployed as $467,639.27 of
|
||
// new lots, with the $290.59 difference topped up from the
|
||
// account's own cash. Every dollar is internal.
|
||
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("OLDFUND", 11.32);
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "OLDFUND", .shares = 41_285.219, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage", .drip = true },
|
||
.{ .symbol = "", .shares = 309.57, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "NEWA", .shares = 13_747, .open_date = Date.fromYmd(2026, 8, 11), .open_price = 23.25, .account = "Sample Brokerage" },
|
||
.{ .symbol = "NEWA", .shares = 1_436, .open_date = Date.fromYmd(2026, 8, 13), .open_price = 23.32, .account = "Sample Brokerage" },
|
||
.{ .symbol = "NEWB", .shares = 1_800, .open_date = Date.fromYmd(2026, 8, 11), .open_price = 63.63, .account = "Sample Brokerage" },
|
||
.{ .symbol = "", .shares = 18.98, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
|
||
|
||
var sale: ?Change = null;
|
||
var new_total: f64 = 0;
|
||
var attributed_total: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.lot_removed => sale = c,
|
||
.new_stock => {
|
||
new_total += c.value();
|
||
attributed_total += c.attributedValue();
|
||
},
|
||
else => {},
|
||
};
|
||
|
||
// The sale is valued, and is an outflow - never a negative contribution.
|
||
try std.testing.expect(sale != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 467_348.68), sale.?.face_value, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, -467_348.68), sale.?.value(), 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), sale.?.attributedValue(), 0.01);
|
||
|
||
// Proceeds plus the $290.59 of account cash fund the buys exactly.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 467_639.27), new_total, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), attributed_total, 0.01);
|
||
}
|
||
|
||
test "computeReport: case B - sale proceeds resting in cash are not a contribution" {
|
||
// Window 1 of a two-window reallocation: the position is sold and
|
||
// the proceeds just sit in the account's cash line. On an account
|
||
// WITHOUT cash_is_contribution the increase is plain cash_delta,
|
||
// which never counted - this pins that it stays that way now the
|
||
// sale carries dollars.
|
||
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 = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample Brokerage" },
|
||
.{ .symbol = "", .shares = 500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "", .shares = 100_500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||
|
||
var total: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => total += c.attributedValue(),
|
||
else => {},
|
||
};
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), total, 0.01);
|
||
}
|
||
|
||
test "computeReport: case C - cash from a prior window's sale funds the rebuy" {
|
||
// Window 2: the cash parked in window 1 buys the replacement.
|
||
// Already worked before this change (a negative cash_delta funds
|
||
// the buy); pinned so the new proceeds path cannot regress it.
|
||
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 = 100_500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "NEWSYM", .shares = 2_000, .open_date = Date.fromYmd(2026, 5, 10), .open_price = 50.0, .account = "Sample Brokerage" },
|
||
.{ .symbol = "", .shares = 500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{});
|
||
|
||
var total: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => total += c.attributedValue(),
|
||
else => {},
|
||
};
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), total, 0.01);
|
||
}
|
||
|
||
test "computeReport: case D - a sale resting in cash must not swallow a real deposit" {
|
||
// The case that makes the spent/resting split load-bearing. In one
|
||
// window: SYM is sold and the proceeds stay in cash, AND a
|
||
// separate $10k of new money arrives and is invested in VTI.
|
||
//
|
||
// A naive "sale proceeds fund new lots" budget would let the
|
||
// $100k sale absorb the $10k VTI purchase and report $0. The
|
||
// proceeds are demonstrably still in cash (the pool went UP), so
|
||
// they are unavailable for funding and the $10k must survive.
|
||
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 = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample Brokerage" },
|
||
.{ .symbol = "", .shares = 1_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
// $100k proceeds land in cash; the $10k deposit passes through
|
||
// and out again into VTI, so the pool ends up +$100k.
|
||
.{ .symbol = "", .shares = 101_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
.{ .symbol = "VTI", .shares = 40, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 250.0, .account = "Sample Brokerage" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||
|
||
var vti: ?Change = null;
|
||
for (report.changes) |c| {
|
||
if (c.kind == .new_stock and std.mem.eql(u8, c.symbol, "VTI")) vti = c;
|
||
}
|
||
try std.testing.expect(vti != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 10_000.0), vti.?.value(), 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), vti.?.internal_funded, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 10_000.0), vti.?.attributedValue(), 0.01);
|
||
}
|
||
|
||
test "computeReport: case E/F - deposits still count, parked or invested" {
|
||
// Guard rails on the other side: with no sale anywhere, a deposit
|
||
// is a contribution whether it is left as cash (E) or spent on a
|
||
// security in the same window (F).
|
||
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();
|
||
|
||
// E: cash pool grows, nothing bought. Plain cash_delta on an
|
||
// account without the opt-in flag, so it is deliberately NOT
|
||
// counted as a contribution - only the flag makes cash count.
|
||
const before_e = [_]Lot{
|
||
.{ .symbol = "", .shares = 1_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
};
|
||
const after_e = [_]Lot{
|
||
.{ .symbol = "", .shares = 11_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
};
|
||
const rep_e = try computeReport(allocator, &before_e, &after_e, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||
var cash_e: ?Change = null;
|
||
for (rep_e.changes) |c| {
|
||
if (c.kind == .cash_delta) cash_e = c;
|
||
}
|
||
try std.testing.expect(cash_e != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 10_000.0), cash_e.?.value(), 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), cash_e.?.internal_funded, 0.01);
|
||
|
||
// F: deposit arrives and is invested in the same window - cash
|
||
// ends flat, the new lot is real new money.
|
||
const before_f = [_]Lot{
|
||
.{ .symbol = "", .shares = 1_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
};
|
||
const after_f = [_]Lot{
|
||
.{ .symbol = "", .shares = 1_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||
.{ .symbol = "VTI", .shares = 40, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 250.0, .account = "Sample Brokerage" },
|
||
};
|
||
const rep_f = try computeReport(allocator, &before_f, &after_f, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||
var vti: ?Change = null;
|
||
for (rep_f.changes) |c| {
|
||
if (c.kind == .new_stock) vti = c;
|
||
}
|
||
try std.testing.expect(vti != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 10_000.0), vti.?.attributedValue(), 0.01);
|
||
}
|
||
|
||
test "computeReport: a partial sale funds a same-account purchase" {
|
||
// Shares reduced on an unchanged lot key (drip_negative) is still
|
||
// a sale, so its proceeds are a funding source too.
|
||
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 = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
// 200 shares sold at $100 = $20k, redeployed into NEWSYM.
|
||
.{ .symbol = "SYM", .shares = 800, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample Brokerage" },
|
||
.{ .symbol = "NEWSYM", .shares = 400, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 50.0, .account = "Sample Brokerage" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||
|
||
var partial: ?Change = null;
|
||
var buy: ?Change = null;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.drip_negative => partial = c,
|
||
.new_stock => buy = c,
|
||
else => {},
|
||
};
|
||
try std.testing.expect(partial != null);
|
||
try std.testing.expect(buy != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, -20_000.0), partial.?.value(), 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), partial.?.attributedValue(), 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), buy.?.internal_funded, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
|
||
}
|
||
|
||
test "computeReport: a sale does NOT fund a purchase in a different account" {
|
||
// Same-account only. Selling in one account cannot silently
|
||
// explain a purchase in another - that is a transfer, and it needs
|
||
// a transaction_log.srf record.
|
||
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 = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample IRA" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "NEWSYM", .shares = 2_000, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 50.0, .account = "Sample Brokerage" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||
|
||
var buy: ?Change = null;
|
||
for (report.changes) |c| {
|
||
if (c.kind == .new_stock) buy = c;
|
||
}
|
||
try std.testing.expect(buy != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.internal_funded, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_000.0), buy.?.attributedValue(), 0.01);
|
||
}
|
||
|
||
test "computeReport: sale proceeds cancel a cash_is_contribution credit" {
|
||
// The latent bug. On an account flagged cash_is_contribution::true
|
||
// a positive cash delta is assumed to be new money - true for a
|
||
// payroll accrual, false for the proceeds of a sale, and the flag
|
||
// cannot tell them apart. Without the resting-proceeds drawdown
|
||
// this books a $100k sale as a $100k 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();
|
||
try prices.put("SYM", 100.0);
|
||
|
||
var am = try analysis.parseAccountsFile(allocator,
|
||
\\#!srfv1
|
||
\\account::Sample ESPP,tax_type::taxable,cash_is_contribution:bool:true
|
||
);
|
||
defer am.deinit();
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "SYM", .shares = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample ESPP" },
|
||
.{ .symbol = "", .shares = 500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample ESPP" },
|
||
};
|
||
const after = [_]Lot{
|
||
// $100k of proceeds land in the cash line, plus $2k of genuine
|
||
// payroll accrual that the flag exists to catch.
|
||
.{ .symbol = "", .shares = 102_500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample ESPP" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||
.account_map = &am,
|
||
});
|
||
|
||
var credit: ?Change = null;
|
||
for (report.changes) |c| {
|
||
if (c.kind == .cash_contribution) credit = c;
|
||
}
|
||
try std.testing.expect(credit != null);
|
||
// Raw cash arrival is $102k; $100k of it is sale proceeds, so only
|
||
// the $2k accrual is a real contribution.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 102_000.0), credit.?.value(), 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_000.0), credit.?.internal_funded, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 2_000.0), credit.?.attributedValue(), 0.01);
|
||
}
|
||
|
||
test "computeReport: cash_is_contribution credit survives when there is no sale" {
|
||
// The other half of the pin above: with no sale in the window the
|
||
// flag must still credit the full cash arrival, or the opt-in
|
||
// stops doing its 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();
|
||
|
||
var am = try analysis.parseAccountsFile(allocator,
|
||
\\#!srfv1
|
||
\\account::Sample ESPP,tax_type::taxable,cash_is_contribution:bool:true
|
||
);
|
||
defer am.deinit();
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "", .shares = 500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample ESPP" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "", .shares = 2_500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample ESPP" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||
.account_map = &am,
|
||
});
|
||
|
||
var credit: ?Change = null;
|
||
for (report.changes) |c| {
|
||
if (c.kind == .cash_contribution) credit = c;
|
||
}
|
||
try std.testing.expect(credit != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), credit.?.internal_funded, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 2_000.0), credit.?.attributedValue(), 0.01);
|
||
}
|
||
|
||
// ── Positions closed in place (`position_closed`) ─────────────
|
||
|
||
test "computeReport: a lot closed in place is a sale valued at close_price" {
|
||
// Archiving a sold lot - into a sibling `portfolio_closed.srf` that
|
||
// the same glob merges, or edited where it sits - keeps the record
|
||
// and adds close_date/close_price. The strict lot key excludes
|
||
// close_date and the share count is untouched, so without an
|
||
// explicit close check this emitted nothing at all and the sale
|
||
// vanished from attribution.
|
||
//
|
||
// close_price is authoritative: $11.32 realized against an $8.90
|
||
// basis. Valuing at basis would leave the difference as a phantom
|
||
// 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();
|
||
try prices.put("OLDFUND", 9.00); // deliberately NOT the close price
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "OLDFUND", .shares = 41_285.219, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "OLDFUND", .shares = 41_285.219, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 8, 10), .close_price = 11.32 },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
const sale = report.changes[0];
|
||
try std.testing.expectEqual(ChangeKind.position_closed, sale.kind);
|
||
// 41,285.219 x $11.32 - close_price wins over both the current
|
||
// price in the map and open_price.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 467_348.68), sale.face_value, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), sale.attributedValue(), 0.01);
|
||
}
|
||
|
||
test "computeReport: closing a position funds a same-account repurchase" {
|
||
// The full reallocation as it looks once closed lots are retained
|
||
// rather than deleted: one position_closed plus the replacement
|
||
// buy, netting to zero new money.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const allocator = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "OLDFUND", .shares = 1_000, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "OLDFUND", .shares = 1_000, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 8, 10), .close_price = 11.32 },
|
||
.{ .symbol = "NEWA", .shares = 486, .open_date = Date.fromYmd(2026, 8, 11), .open_price = 23.29, .account = "Sample Brokerage" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
|
||
|
||
var buy: ?Change = null;
|
||
for (report.changes) |c| {
|
||
if (c.kind == .new_stock) buy = c;
|
||
}
|
||
try std.testing.expect(buy != null);
|
||
// $11,320 of proceeds cover the $11,318.94 purchase in full.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 11_318.94), buy.?.value(), 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
|
||
}
|
||
|
||
test "computeReport: a close with no close_price falls back to a price proxy" {
|
||
// close_price is optional in the schema. Without it the sale still
|
||
// has to be valued or it funds nothing, so fall back to the same
|
||
// current-price proxy a deleted lot gets - never to open_price,
|
||
// which would understate a winner.
|
||
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("OLDFUND", 11.00);
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "OLDFUND", .shares = 1_000, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "OLDFUND", .shares = 1_000, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 8, 10) },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.position_closed, report.changes[0].kind);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 11_000.0), report.changes[0].face_value, 0.01);
|
||
}
|
||
|
||
test "computeReport: a close that also moves price:: is a sale, not a price edit" {
|
||
// Ordering pin. The same-shares branch checks `price::` too, and a
|
||
// reconciliation pass can easily stamp a final price on the way
|
||
// out. If the price comparison ran first this would classify as
|
||
// price_only, emit no outflow, and leave the repurchase looking
|
||
// like new money.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const allocator = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "MFUND", .shares = 100, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 10.0, .price = 12.0, .account = "Sample Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "MFUND", .shares = 100, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 10.0, .price = 13.0, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 8, 10), .close_price = 13.0 },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.position_closed, report.changes[0].kind);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1_300.0), report.changes[0].face_value, 0.01);
|
||
}
|
||
|
||
test "computeReport: an option outflow is valued per contract, not per share" {
|
||
// Option lots price at open_price x multiplier (100 shares per
|
||
// contract), so an outflow that skipped the multiplier would
|
||
// undervalue the proceeds 100x and fund almost 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{
|
||
// 5 contracts at $3.20 = $1,600.
|
||
.{ .symbol = "SYM 260918C00050000", .shares = 5, .open_date = Date.fromYmd(2026, 5, 1), .open_price = 3.20, .security_type = .option, .account = "Sample Brokerage" },
|
||
// A closed-in-place option, valued off close_price: 2 x $4.50 x 100.
|
||
.{ .symbol = "SYM 261218C00060000", .shares = 2, .open_date = Date.fromYmd(2026, 5, 1), .open_price = 2.00, .security_type = .option, .account = "Sample Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "SYM 261218C00060000", .shares = 2, .open_date = Date.fromYmd(2026, 5, 1), .open_price = 2.00, .security_type = .option, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 8, 10), .close_price = 4.50 },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
|
||
|
||
var removed: ?Change = null;
|
||
var closed: ?Change = null;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.lot_removed => removed = c,
|
||
.position_closed => closed = c,
|
||
else => {},
|
||
};
|
||
try std.testing.expect(removed != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1_600.0), removed.?.face_value, 0.01);
|
||
try std.testing.expect(closed != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 900.0), closed.?.face_value, 0.01);
|
||
}
|
||
|
||
test "computeReport: a cash lot closed in place drains the pool, it is not a sale" {
|
||
// Closing a cash line rather than deleting it still means the cash
|
||
// left. It must reduce the account's cash pool, not masquerade as
|
||
// sale proceeds - otherwise the same dollars would be counted as
|
||
// both an outflow and a funding source, funding the buy twice.
|
||
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 = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 5, 3) },
|
||
.{ .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 buy: ?Change = null;
|
||
for (report.changes) |c| {
|
||
if (c.kind == .new_stock) buy = c;
|
||
}
|
||
try std.testing.expect(buy != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), buy.?.internal_funded, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
|
||
}
|
||
|
||
test "printCollapsedSales: many retired lots render as one line" {
|
||
// A DRIP-fed position closes as one lot per historical
|
||
// distribution. The trade that motivated this retired 219 of them;
|
||
// printed individually they buried every other section.
|
||
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();
|
||
|
||
var before: std.ArrayList(Lot) = .empty;
|
||
var after: std.ArrayList(Lot) = .empty;
|
||
for (0..40) |i| {
|
||
const day: u8 = @intCast(1 + i % 28);
|
||
const lot: Lot = .{ .symbol = "DRIPX", .shares = 10, .open_date = Date.fromYmd(2020, 1, day), .open_price = 9.0 + @as(f64, @floatFromInt(i)) * 0.01, .account = "Sample Brokerage" };
|
||
try before.append(allocator, lot);
|
||
var closed = lot;
|
||
closed.close_date = Date.fromYmd(2026, 8, 10);
|
||
closed.close_price = 12.0;
|
||
try after.append(allocator, closed);
|
||
}
|
||
|
||
const report = try computeReport(allocator, before.items, after.items, &prices, Date.fromYmd(2026, 8, 17), .{});
|
||
try std.testing.expectEqual(@as(usize, 40), report.changes.len);
|
||
|
||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||
try printReport(&aw.writer, &report, "test window", false);
|
||
const text = aw.written();
|
||
|
||
// Exactly one DRIPX row, carrying the lot count and the total.
|
||
var rows: usize = 0;
|
||
var it = std.mem.splitScalar(u8, text, '\n');
|
||
while (it.next()) |line| {
|
||
if (std.mem.indexOf(u8, line, "DRIPX") != null) rows += 1;
|
||
}
|
||
try std.testing.expectEqual(@as(usize, 1), rows);
|
||
try std.testing.expect(std.mem.indexOf(u8, text, "sold 40 lots at close") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, text, "$4,800.00") != null);
|
||
}
|
||
|
||
test "computeReport: a declared cash transfer out does not consume a security sale" {
|
||
// `tryMatchFromSide` looks for the sending leg of a declared
|
||
// `type::cash` transfer. Its removal arm was written for a fully
|
||
// drained CASH lot, whose dollars live in `face_value` - but it was
|
||
// unreachable while `value()` was 0 for every removal, so nothing
|
||
// pinned that intent.
|
||
//
|
||
// Now that outflows carry dollars the arm is live, and an unrelated
|
||
// security sale in the same account is a candidate. If it wins, the
|
||
// sale flips to `transfer_out`, drops out of the sale-proceeds
|
||
// budget, and the rebuy it paid for reads as new money - the exact
|
||
// bug this whole change set exists to remove.
|
||
//
|
||
// Here: $50k of cash is declared as moving out of Sample IRA, and
|
||
// separately SYM is sold for $100k and NEWSYM bought with the
|
||
// proceeds. The transfer must take the cash; the reallocation must
|
||
// still net to zero.
|
||
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 = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample IRA" },
|
||
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
|
||
};
|
||
const after = [_]Lot{
|
||
// SYM sold, proceeds straight into NEWSYM.
|
||
.{ .symbol = "NEWSYM", .shares = 2_000, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 50.0, .account = "Sample IRA" },
|
||
// The $50k cash left, as declared.
|
||
.{ .symbol = "", .shares = 0, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
|
||
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Roth IRA" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Sample IRA,to::Sample Roth IRA,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||
.transfer_log = tlog.transfers,
|
||
});
|
||
|
||
// The SYM sale must remain a sale, not be eaten as the transfer's
|
||
// sending leg.
|
||
var sale: ?Change = null;
|
||
var buy: ?Change = null;
|
||
for (report.changes) |c| {
|
||
if (std.mem.eql(u8, c.symbol, "SYM") and isSaleKind(c)) sale = c;
|
||
if (c.kind == .new_stock and std.mem.eql(u8, c.symbol, "NEWSYM")) buy = c;
|
||
}
|
||
try std.testing.expect(sale != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_000.0), sale.?.face_value, 0.01);
|
||
|
||
// ...so its proceeds still fund the rebuy, which is not new money.
|
||
try std.testing.expect(buy != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_000.0), buy.?.internal_funded, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
|
||
}
|
||
|
||
test "computeReport: an untracked transfer source does not consume a security sale" {
|
||
// The dangerous shape. A `type::cash` transfer's sending account is
|
||
// allowed to be unmodelled - the docs say a missing source side is
|
||
// not an error. But `tryMatchFromSide` scans for ANY qualifying
|
||
// outflow on that account, so with no cash change to claim, an
|
||
// unrelated security sale is the only candidate left.
|
||
//
|
||
// Flipping it to `transfer_out` removes it from the sale-proceeds
|
||
// budget, and the rebuy it paid for reads as new money again. The
|
||
// sending leg of a CASH transfer must be cash: a fully drained cash
|
||
// lot (whose dollars live in `face_value`) or a negative
|
||
// `cash_delta`, never a security.
|
||
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 = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample IRA" },
|
||
};
|
||
const after = [_]Lot{
|
||
// Reallocation inside Sample IRA: SYM out, NEWSYM in.
|
||
.{ .symbol = "NEWSYM", .shares = 2_000, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 50.0, .account = "Sample IRA" },
|
||
// The declared transfer's destination. Its source cash is not
|
||
// modelled in the portfolio at all.
|
||
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Sample Roth IRA" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Sample IRA,to::Sample Roth IRA,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||
.transfer_log = tlog.transfers,
|
||
});
|
||
|
||
var sale: ?Change = null;
|
||
var buy: ?Change = null;
|
||
for (report.changes) |c| {
|
||
if (std.mem.eql(u8, c.symbol, "SYM")) sale = c;
|
||
if (c.kind == .new_stock and std.mem.eql(u8, c.symbol, "NEWSYM")) buy = c;
|
||
}
|
||
|
||
// The sale stays a sale.
|
||
try std.testing.expect(sale != null);
|
||
try std.testing.expectEqual(ChangeKind.lot_removed, sale.?.kind);
|
||
|
||
// And still funds the rebuy, so no new money is reported.
|
||
try std.testing.expect(buy != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_000.0), buy.?.internal_funded, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
|
||
}
|
||
|
||
test "computeReport: a fully drained cash lot is the transfer's sending leg" {
|
||
// The shape `tryMatchFromSide`'s removal arm was written for, and
|
||
// which never worked: when the cash line is spent to the cent the
|
||
// user deletes it, so the outflow arrives as a removed cash lot
|
||
// rather than a negative `cash_delta`. Its dollars live in
|
||
// `face_value` because `value()` was 0 for removals - which is
|
||
// exactly why the arm could never fire.
|
||
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 IRA" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Sample Roth IRA" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Sample IRA,to::Sample Roth IRA,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||
.transfer_log = tlog.transfers,
|
||
});
|
||
|
||
var n_out: usize = 0;
|
||
for (report.changes) |c| {
|
||
if (c.kind == .transfer_out and std.mem.eql(u8, c.account, "Sample IRA")) n_out += 1;
|
||
}
|
||
try std.testing.expectEqual(@as(usize, 1), n_out);
|
||
|
||
// And the whole move contributes nothing.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), attributionTotalForTest(report), 0.01);
|
||
}
|
||
|
||
test "computeReport: an account that GAINED cash is not a transfer's sending leg" {
|
||
// `cash_delta` is signed and the match used to take its magnitude,
|
||
// so an account whose cash went UP could be reported as the source
|
||
// of a transfer out. Money leaving is the whole point, so the sign
|
||
// has to be checked.
|
||
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 = 10_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
|
||
};
|
||
const after = [_]Lot{
|
||
// Sample IRA's cash rose by $60k - it cannot be what funded a
|
||
// transfer out of it.
|
||
.{ .symbol = "", .shares = 70_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
|
||
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Sample Roth IRA" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Sample IRA,to::Sample Roth IRA,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||
.transfer_log = tlog.transfers,
|
||
});
|
||
|
||
for (report.changes) |c| {
|
||
try std.testing.expect(c.kind != .transfer_out);
|
||
}
|
||
}
|
||
|
||
test "prepareReport: a lot archived into a sibling portfolio file is one sale" {
|
||
// End-to-end pin on the multi-file diff. The documented workflow for
|
||
// a sale is to move the closed lot out of `portfolio.srf` into a
|
||
// sibling `portfolio_closed.srf`, which the `portfolio*.srf` glob
|
||
// picks up.
|
||
//
|
||
// Reading only the first file - what this used to do - showed the
|
||
// deletion and nothing else, so the sale's proceeds were invisible
|
||
// and the repurchase read as a fresh contribution. Reading the
|
||
// merged union instead sees the lot gain close_date/close_price,
|
||
// and prices the sale off what it actually realized.
|
||
if (!test_git.available(std.testing.allocator)) return;
|
||
|
||
const io = std.testing.io;
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const allocator = arena_state.allocator();
|
||
|
||
var tmp = std.testing.tmpDir(.{});
|
||
defer tmp.cleanup();
|
||
|
||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||
const dir = path_buf[0..dir_len];
|
||
|
||
// Before: the position is open, and an empty archive file already
|
||
// exists so the glob resolves the same on both sides.
|
||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data =
|
||
\\#!srfv1
|
||
\\symbol::OLDFUND,shares:num:1000,open_date::2021-09-06,open_price:num:8.90,account::Sample Brokerage
|
||
\\
|
||
});
|
||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_closed.srf", .data = "#!srfv1\n" });
|
||
try test_git.run(allocator, dir, null, &.{ "init", "-q" });
|
||
try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" });
|
||
try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" });
|
||
try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" });
|
||
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf", "portfolio_closed.srf" });
|
||
try test_git.run(allocator, dir, "2026-08-01T12:00:00", &.{ "commit", "-q", "-m", "open" });
|
||
|
||
// After: sold at 11.32 and archived; proceeds redeployed into NEWA.
|
||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data =
|
||
\\#!srfv1
|
||
\\symbol::NEWA,shares:num:486,open_date::2026-08-11,open_price:num:23.29,account::Sample Brokerage
|
||
\\
|
||
});
|
||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_closed.srf", .data =
|
||
\\#!srfv1
|
||
\\symbol::OLDFUND,close_date::2026-08-10,close_price:num:11.32,shares:num:1000,open_date::2021-09-06,open_price:num:8.90,account::Sample Brokerage
|
||
\\
|
||
});
|
||
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf", "portfolio_closed.srf" });
|
||
try test_git.run(allocator, dir, "2026-08-15T12:00:00", &.{ "commit", "-q", "-m", "sold" });
|
||
|
||
var env = try std.testing.environ.createMap(allocator);
|
||
defer env.deinit();
|
||
|
||
const open_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
||
const closed_path = try std.fs.path.join(allocator, &.{ dir, "portfolio_closed.srf" });
|
||
// Lexicographic order, matching how the glob resolves ('.' < '_').
|
||
const paths: []const []const u8 = &.{ open_path, closed_path };
|
||
|
||
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
|
||
defer svc.deinit();
|
||
|
||
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 8, 17), false, .never, .silent) catch return;
|
||
defer ctx.deinit();
|
||
|
||
var sale: ?Change = null;
|
||
var buy: ?Change = null;
|
||
for (ctx.report.changes) |c| switch (c.kind) {
|
||
.position_closed => sale = c,
|
||
.new_stock => buy = c,
|
||
else => {},
|
||
};
|
||
|
||
// The archived lot reads as a close, not a disappearance...
|
||
try std.testing.expect(sale != null);
|
||
try std.testing.expectEqualStrings("OLDFUND", sale.?.symbol);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 11_320.0), sale.?.face_value, 0.01);
|
||
// ...and its proceeds fund the repurchase, so nothing is new money.
|
||
try std.testing.expect(buy != null);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), summarizeAttribution(ctx).total(), 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.
|
||
// Prior behavior: phantom new_stock for the full lot value.
|
||
// After edit detection: lot_edited.
|
||
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("TAXLOSS", 50.0);
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "TAXLOSS", .shares = 100, .open_date = Date.fromYmd(2026, 1, 15), .open_price = 45.0, .account = "Tax Loss" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "TAXLOSS", .shares = 100, .open_date = Date.fromYmd(2026, 1, 15), .open_price = 48.0, .account = "Tax Loss" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);
|
||
}
|
||
|
||
test "computeReport: symbol rename with ticker alias collapses to lot_edited" {
|
||
// 2026-05-02 regression: user changed a tax-loss lot from
|
||
// `symbol::SPY` to `symbol::DI-SPX, ticker::SPY` (direct-indexing
|
||
// proxy) and tweaked shares by ~1% during reconciliation. Same
|
||
// underlying SPY exposure, same account, same open_date /
|
||
// open_price - should collapse to an edit, NOT a ~$327k phantom
|
||
// contribution.
|
||
//
|
||
// Before the `priceSymbol()`-based secondary key, the raw-symbol
|
||
// comparison treated "SPY" and "DI-SPX" as different positions
|
||
// and emitted a `new_stock` + `lot_removed` pair worth the full
|
||
// lot value (~715 × $461 = $330k).
|
||
//
|
||
// After the fix: one `lot_edited` for the identity continuity,
|
||
// plus a `rollup_delta` for the ~6-share residual worth ~$3k.
|
||
// The attribution total sees only the residual, not the full lot.
|
||
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("SPY", 461.24);
|
||
|
||
const before = [_]Lot{
|
||
.{
|
||
.symbol = "SPY",
|
||
.shares = 715.912037,
|
||
.open_date = Date.fromYmd(2026, 2, 25),
|
||
.open_price = 461.240208,
|
||
.account = "Tax Loss",
|
||
},
|
||
};
|
||
const after = [_]Lot{
|
||
.{
|
||
.symbol = "DI-SPX",
|
||
.ticker = "SPY",
|
||
.shares = 709.235272, // ~1% tweak during reconciliation
|
||
.open_date = Date.fromYmd(2026, 2, 25),
|
||
.open_price = 461.240208,
|
||
.account = "Tax Loss",
|
||
.price_ratio = 1.0,
|
||
},
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
|
||
|
||
// Expect one lot_edited + one residual share-delta change
|
||
// (rollup_delta is used here because delta < 0 would be
|
||
// drip_negative - but 709 < 715 means after < before, so
|
||
// drip_negative). Actually delta = 709 - 715 = -6, so
|
||
// drip_negative.
|
||
var n_edit: usize = 0;
|
||
var n_rollup: usize = 0;
|
||
var n_drip_neg: usize = 0;
|
||
var n_new_stock: usize = 0;
|
||
var n_removed: usize = 0;
|
||
var residual_value: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.lot_edited => n_edit += 1,
|
||
.rollup_delta => {
|
||
n_rollup += 1;
|
||
residual_value = c.value();
|
||
},
|
||
.drip_negative => {
|
||
n_drip_neg += 1;
|
||
residual_value = c.value();
|
||
},
|
||
.new_stock => n_new_stock += 1,
|
||
.lot_removed => n_removed += 1,
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 1), n_edit);
|
||
try std.testing.expectEqual(@as(usize, 0), n_new_stock);
|
||
try std.testing.expectEqual(@as(usize, 0), n_removed);
|
||
// Share count went DOWN, so we get a drip_negative for the
|
||
// residual (not rollup_delta).
|
||
try std.testing.expectEqual(@as(usize, 1), n_drip_neg);
|
||
try std.testing.expectEqual(@as(usize, 0), n_rollup);
|
||
// ~6.68 shares × 461.24 ≈ $3,080 - the real reconciliation-scale
|
||
// movement, not the ~$330k phantom.
|
||
try std.testing.expect(residual_value < 0); // drip_negative sign
|
||
try std.testing.expect(@abs(residual_value) < 5_000); // nowhere near $330k
|
||
}
|
||
|
||
test "computeReport: direct_indexing account suppresses sub-1% residual" {
|
||
// Tax-loss direct-indexing proxy: same symbol/account/key but
|
||
// small share drift (0.5%) from tracking-error reconciliation.
|
||
// With no account_map, the default 0.01% tolerance surfaces this
|
||
// as a rollup_delta. With account_map flagging the account as
|
||
// direct_indexing, the looser 1% tolerance swallows it - only
|
||
// the lot_edited marker is emitted, no residual that would land
|
||
// in the attribution 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("SPY", 461.24);
|
||
|
||
const before = [_]Lot{
|
||
.{
|
||
.symbol = "SPY",
|
||
.shares = 1000.0,
|
||
.open_date = Date.fromYmd(2026, 2, 25),
|
||
.open_price = 461.240208,
|
||
.account = "Tax Loss",
|
||
},
|
||
};
|
||
const after = [_]Lot{
|
||
// Broken strict key (symbol+ticker alias rewrite) plus a
|
||
// 0.5% share decrease from tracking-error reconciliation.
|
||
.{
|
||
.symbol = "DI-SPX",
|
||
.ticker = "SPY",
|
||
.shares = 995.0,
|
||
.open_date = Date.fromYmd(2026, 2, 25),
|
||
.open_price = 461.240208,
|
||
.account = "Tax Loss",
|
||
.price_ratio = 1.0,
|
||
},
|
||
};
|
||
|
||
// Build an account_map flagging Tax Loss as direct-indexing.
|
||
var am_entries = [_]analysis.AccountTaxEntry{
|
||
.{
|
||
.account = "Tax Loss",
|
||
.tax_type = .taxable,
|
||
.direct_indexing = true,
|
||
},
|
||
};
|
||
const account_map = analysis.AccountMap{
|
||
.entries = &am_entries,
|
||
.allocator = allocator,
|
||
};
|
||
|
||
const report = try computeReport(
|
||
allocator,
|
||
&before,
|
||
&after,
|
||
&prices,
|
||
Date.fromYmd(2026, 4, 21),
|
||
.{ .account_map = &account_map },
|
||
);
|
||
|
||
var n_edit: usize = 0;
|
||
var n_rollup_or_drip: usize = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.lot_edited => n_edit += 1,
|
||
.rollup_delta, .drip_negative, .drip_confirmed => n_rollup_or_drip += 1,
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 1), n_edit);
|
||
// Looser tolerance swallows the 0.5% residual - no rollup/drip
|
||
// leak into the attribution total.
|
||
try std.testing.expectEqual(@as(usize, 0), n_rollup_or_drip);
|
||
}
|
||
|
||
test "computeReport: direct_indexing same-key drift suppressed in Pass 1" {
|
||
// Tax-loss direct-indexing proxy with SAME strict key on both
|
||
// sides (no rename this week) but small share drift from
|
||
// tracking-error reconciliation. Pass 1 would normally emit a
|
||
// drip_negative; with direct_indexing the 1% tolerance suppresses
|
||
// it the same way detectEdits does.
|
||
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("SPY", 461.24);
|
||
|
||
const before = [_]Lot{
|
||
.{
|
||
.symbol = "DI-SPX",
|
||
.ticker = "SPY",
|
||
.shares = 1000.0,
|
||
.open_date = Date.fromYmd(2026, 2, 25),
|
||
.open_price = 461.240208,
|
||
.account = "Tax Loss",
|
||
},
|
||
};
|
||
const after = [_]Lot{
|
||
// Same strict key, 0.5% share decrease from tracking-error
|
||
// reconciliation.
|
||
.{
|
||
.symbol = "DI-SPX",
|
||
.ticker = "SPY",
|
||
.shares = 995.0,
|
||
.open_date = Date.fromYmd(2026, 2, 25),
|
||
.open_price = 461.240208,
|
||
.account = "Tax Loss",
|
||
},
|
||
};
|
||
|
||
var am_entries = [_]analysis.AccountTaxEntry{
|
||
.{
|
||
.account = "Tax Loss",
|
||
.tax_type = .taxable,
|
||
.direct_indexing = true,
|
||
},
|
||
};
|
||
const account_map = analysis.AccountMap{
|
||
.entries = &am_entries,
|
||
.allocator = allocator,
|
||
};
|
||
|
||
const report = try computeReport(
|
||
allocator,
|
||
&before,
|
||
&after,
|
||
&prices,
|
||
Date.fromYmd(2026, 4, 21),
|
||
.{ .account_map = &account_map },
|
||
);
|
||
|
||
// No rollup/drip - the share drift was tracking error and the
|
||
// direct_indexing flag suppressed it.
|
||
try std.testing.expectEqual(@as(usize, 0), report.changes.len);
|
||
}
|
||
|
||
test "computeReport: same-key drift without direct_indexing still surfaces (sanity)" {
|
||
// Sanity check that the flag is what's doing the suppression:
|
||
// same setup as above but without the account_map, so Pass 1
|
||
// emits a drip_negative for the 0.5% drift.
|
||
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("SPY", 461.24);
|
||
|
||
const before = [_]Lot{
|
||
.{
|
||
.symbol = "DI-SPX",
|
||
.ticker = "SPY",
|
||
.shares = 1000.0,
|
||
.open_date = Date.fromYmd(2026, 2, 25),
|
||
.open_price = 461.240208,
|
||
.account = "Tax Loss",
|
||
},
|
||
};
|
||
const after = [_]Lot{
|
||
.{
|
||
.symbol = "DI-SPX",
|
||
.ticker = "SPY",
|
||
.shares = 995.0,
|
||
.open_date = Date.fromYmd(2026, 2, 25),
|
||
.open_price = 461.240208,
|
||
.account = "Tax Loss",
|
||
},
|
||
};
|
||
|
||
const report = try computeReport(
|
||
allocator,
|
||
&before,
|
||
&after,
|
||
&prices,
|
||
Date.fromYmd(2026, 4, 21),
|
||
.{},
|
||
);
|
||
|
||
var n_drip_neg: usize = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.drip_negative => n_drip_neg += 1,
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 1), n_drip_neg);
|
||
}
|
||
|
||
test "computeReport: direct_indexing tolerance still surfaces real contributions" {
|
||
// Regression: the 1% tolerance should still catch a real
|
||
// contribution. If the user transfers $100k into a multi-million
|
||
// direct-indexing account (~1.2% of value), the residual should
|
||
// surface as a rollup_delta so the attribution isn't silent about
|
||
// real money flow.
|
||
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("SPY", 461.24);
|
||
|
||
// 17,361 shares × $461 ≈ $8M (same scale as the user's real tax
|
||
// loss account). A $100k contribution = ~217 shares = ~1.25%.
|
||
const before = [_]Lot{
|
||
.{
|
||
.symbol = "DI-SPX",
|
||
.ticker = "SPY",
|
||
.shares = 17361.0,
|
||
.open_date = Date.fromYmd(2026, 2, 25),
|
||
.open_price = 461.240208,
|
||
.account = "Tax Loss",
|
||
.price_ratio = 1.0,
|
||
},
|
||
};
|
||
const after = [_]Lot{
|
||
// Strict-key break (different open_date - e.g. user
|
||
// redated after a large contribution) AND a 1.25% share
|
||
// increase. Tolerance at 1% fails the "swallow" check so
|
||
// the residual surfaces.
|
||
.{
|
||
.symbol = "DI-SPX",
|
||
.ticker = "SPY",
|
||
.shares = 17578.0, // +217 shares, ≈ +1.25%
|
||
.open_date = Date.fromYmd(2026, 5, 2),
|
||
.open_price = 461.240208,
|
||
.account = "Tax Loss",
|
||
.price_ratio = 1.0,
|
||
},
|
||
};
|
||
|
||
var am_entries = [_]analysis.AccountTaxEntry{
|
||
.{
|
||
.account = "Tax Loss",
|
||
.tax_type = .taxable,
|
||
.direct_indexing = true,
|
||
},
|
||
};
|
||
const account_map = analysis.AccountMap{
|
||
.entries = &am_entries,
|
||
.allocator = allocator,
|
||
};
|
||
|
||
const report = try computeReport(
|
||
allocator,
|
||
&before,
|
||
&after,
|
||
&prices,
|
||
Date.fromYmd(2026, 5, 2),
|
||
.{ .account_map = &account_map },
|
||
);
|
||
|
||
var n_edit: usize = 0;
|
||
var n_rollup: usize = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.lot_edited => n_edit += 1,
|
||
.rollup_delta => n_rollup += 1,
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 1), n_edit);
|
||
// 1.25% is over the 1% tolerance, so we get a rollup_delta.
|
||
try std.testing.expectEqual(@as(usize, 1), n_rollup);
|
||
}
|
||
|
||
test "computeReport: ticker-alias removed (CUSIP-like -> plain ticker) also collapses" {
|
||
// Reverse direction: before has a CUSIP-style symbol with ticker
|
||
// alias, after has the plain ticker with no alias. Both resolve
|
||
// to the same `priceSymbol()` -> edit, not new+removed.
|
||
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("VTTHX", 27.78);
|
||
|
||
const before = [_]Lot{
|
||
.{
|
||
.symbol = "00766V100", // fake CUSIP-style string
|
||
.ticker = "VTTHX",
|
||
.shares = 5000,
|
||
.open_date = Date.fromYmd(2023, 1, 15),
|
||
.open_price = 25.0,
|
||
.account = "401k",
|
||
},
|
||
};
|
||
const after = [_]Lot{
|
||
.{
|
||
.symbol = "VTTHX",
|
||
.shares = 5000,
|
||
.open_date = Date.fromYmd(2023, 1, 15),
|
||
.open_price = 25.0,
|
||
.account = "401k",
|
||
},
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);
|
||
}
|
||
|
||
test "computeReport: different tickers stay distinct (no false collapse)" {
|
||
// Sanity: VOO -> VTI in the same account with a broken strict key
|
||
// is NOT the same underlying position. Must not collapse into an
|
||
// edit. Should classify as new + removed.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const allocator = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
try prices.put("VOO", 450.0);
|
||
try prices.put("VTI", 230.0);
|
||
|
||
const before = [_]Lot{
|
||
.{
|
||
.symbol = "VOO",
|
||
.shares = 100,
|
||
.open_date = Date.fromYmd(2024, 1, 15),
|
||
.open_price = 400.0,
|
||
.account = "IRA",
|
||
},
|
||
};
|
||
const after = [_]Lot{
|
||
.{
|
||
.symbol = "VTI",
|
||
.shares = 100,
|
||
.open_date = Date.fromYmd(2024, 1, 15),
|
||
.open_price = 200.0,
|
||
.account = "IRA",
|
||
},
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
|
||
|
||
var n_edit: usize = 0;
|
||
var n_new: usize = 0;
|
||
var n_removed: usize = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.lot_edited => n_edit += 1,
|
||
.new_stock => n_new += 1,
|
||
.lot_removed => n_removed += 1,
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 0), n_edit);
|
||
try std.testing.expectEqual(@as(usize, 1), n_new);
|
||
try std.testing.expectEqual(@as(usize, 1), n_removed);
|
||
}
|
||
|
||
test "computeReport: account rename is NOT collapsed (documented limitation)" {
|
||
// Renaming an account string in portfolio.srf (e.g. "Brokerage" ->
|
||
// "Joint Brokerage") breaks the secondary key too, since that key
|
||
// includes the account. Edit detection DOES NOT cover this case
|
||
// - the rename looks indistinguishable from a transfer (closing
|
||
// one account, opening another with the same positions). Account
|
||
// renames must therefore be handled manually: either avoid them
|
||
// during a review window, or accept the phantom attribution for
|
||
// that week. Tracked in TODO.md; the user-facing consequences are
|
||
// documented in docs/guides/set-up-accounts.md ("Renaming an
|
||
// account").
|
||
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("AAPL", 180.0);
|
||
try prices.put("MSFT", 400.0);
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Brokerage" },
|
||
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 300.0, .account = "Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Joint Brokerage" },
|
||
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 300.0, .account = "Joint Brokerage" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
|
||
|
||
// Expectation: account rename collapses into regular new+removed
|
||
// classification (NOT lot_edited). If this ever flips to 2/0/0,
|
||
// edit detection has gained account-rename awareness - great,
|
||
// but update this test and the TODO accordingly.
|
||
var n_edit: usize = 0;
|
||
var n_new: usize = 0;
|
||
var n_removed: usize = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.lot_edited => n_edit += 1,
|
||
.new_stock => n_new += 1,
|
||
.lot_removed => n_removed += 1,
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 0), n_edit);
|
||
try std.testing.expectEqual(@as(usize, 2), n_new);
|
||
try std.testing.expectEqual(@as(usize, 2), n_removed);
|
||
}
|
||
|
||
test "computeReport: big share delta with broken key emits lot_edited + residual rollup" {
|
||
// Secondary key matches (same security_type + priceSymbol +
|
||
// account) but the share total diverges significantly. This is
|
||
// the "user broke the lot key AND had a real contribution in
|
||
// the same window" case. The lot identity still collapses to an
|
||
// edit, but the share delta surfaces as a rollup_delta so the
|
||
// attribution total sees the real inflow - not the full lot
|
||
// value as a phantom contribution.
|
||
//
|
||
// Prior behavior (1% share tolerance): fell through to
|
||
// new_stock + lot_removed, over-counting by the full lot 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();
|
||
try prices.put("AAPL", 180.0);
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Brokerage" },
|
||
};
|
||
const after = [_]Lot{
|
||
// Same symbol/account but BIG share delta and different
|
||
// open_date. The 400-share delta is real new money; the
|
||
// 100-share continuation is an edit.
|
||
.{ .symbol = "AAPL", .shares = 500, .open_date = Date.fromYmd(2026, 4, 15), .open_price = 175.0, .account = "Brokerage" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
|
||
|
||
var n_edit: usize = 0;
|
||
var n_rollup: usize = 0;
|
||
var n_new: usize = 0;
|
||
var n_removed: usize = 0;
|
||
var rollup_value: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.lot_edited => n_edit += 1,
|
||
.rollup_delta => {
|
||
n_rollup += 1;
|
||
rollup_value += c.value();
|
||
},
|
||
.new_stock => n_new += 1,
|
||
.lot_removed => n_removed += 1,
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 1), n_edit);
|
||
try std.testing.expectEqual(@as(usize, 1), n_rollup);
|
||
try std.testing.expectEqual(@as(usize, 0), n_new);
|
||
try std.testing.expectEqual(@as(usize, 0), n_removed);
|
||
// 400-share delta × $180 current price = $72k
|
||
try std.testing.expectApproxEqAbs(@as(f64, 72_000.0), rollup_value, 1.0);
|
||
}
|
||
|
||
test "computeReport: tiny share drift emits lot_edited + residual rollup" {
|
||
// Fractional DRIP share-count, e.g. 10.0 -> 10.05 with a
|
||
// reconciliation tweak and a key rewrite. Under the always-collapse
|
||
// design, this produces lot_edited + a small rollup_delta for
|
||
// the 0.05-share residual. The residual survives the 0.01% noise
|
||
// tolerance because 0.5% > 0.01%.
|
||
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("SPY", 500.0);
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "SPY", .shares = 10.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 420.0, .account = "IRA" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "SPY", .shares = 10.05, .open_date = Date.fromYmd(2023, 8, 1), .open_price = 430.0, .account = "IRA" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
|
||
|
||
var n_edit: usize = 0;
|
||
var n_rollup: usize = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.lot_edited => n_edit += 1,
|
||
.rollup_delta => n_rollup += 1,
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 1), n_edit);
|
||
try std.testing.expectEqual(@as(usize, 1), n_rollup);
|
||
}
|
||
|
||
test "computeReport: sub-noise share drift emits only lot_edited" {
|
||
// If the share delta is below the residual-tolerance threshold
|
||
// (0.01%), we emit only lot_edited - no spurious rollup_delta
|
||
// from floating-point noise.
|
||
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("SPY", 500.0);
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "SPY", .shares = 1000.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 420.0, .account = "IRA" },
|
||
};
|
||
const after = [_]Lot{
|
||
// 0.00005% drift (well under 0.01% tolerance)
|
||
.{ .symbol = "SPY", .shares = 1000.0000005, .open_date = Date.fromYmd(2023, 8, 1), .open_price = 430.0, .account = "IRA" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);
|
||
}
|
||
|
||
test "computeReport: new lot with drip::true classified as new_drip_lot" {
|
||
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 = "FAGIX", .shares = 10.0, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 11.00, .account = "Riley IRA", .drip = true },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.new_drip_lot, report.changes[0].kind);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 110.0), report.changes[0].value(), 0.01);
|
||
}
|
||
|
||
test "computeReport: new stock lot without drip flag is 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();
|
||
|
||
const before = [_]Lot{};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 180, .account = "Brokerage" },
|
||
};
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
|
||
}
|
||
|
||
test "computeReport: drip::true existing lot with shares increase is drip_confirmed" {
|
||
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("FAGIX", 11.00);
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Riley IRA", .drip = true },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "FAGIX", .shares = 105, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Riley IRA", .drip = true },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.drip_confirmed, report.changes[0].kind);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 55.0), report.changes[0].value(), 0.01);
|
||
}
|
||
|
||
test "computeReport: per-account totals separate drip_confirmed from rollup" {
|
||
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("FAGIX", 11.00);
|
||
try prices.put("VBTLX", 9.79);
|
||
|
||
const before = [_]Lot{
|
||
.{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true },
|
||
.{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "FAGIX", .shares = 110, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true },
|
||
.{ .symbol = "VBTLX", .shares = 1020, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
|
||
|
||
const t = report.account_totals.get("AcctA") orelse {
|
||
try std.testing.expect(false);
|
||
return;
|
||
};
|
||
// drip_confirmed: 10 * 11 = 110
|
||
try std.testing.expectApproxEqAbs(@as(f64, 110.0), t.drip_confirmed, 0.01);
|
||
// rollup: 20 * 9.79 = 195.8
|
||
try std.testing.expectApproxEqAbs(@as(f64, 195.8), t.rollup, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), t.new_money, 0.01);
|
||
}
|
||
|
||
// ── resolveEndpoints tests ───────────────────────────────────
|
||
//
|
||
// Only the legacy (no-flags) and --since-only branches that don't
|
||
// shell out to git can be unit-tested cheaply. The full flag paths
|
||
// (`--since`, `--since`+`--until`) depend on `git log --until=<DATE>`,
|
||
// which requires a real repo and is covered by `src/git.zig` tests
|
||
// plus manual smoke-testing.
|
||
|
||
test "maybeSnapNote fires for a degraded snapshot_add, and stays quiet for a resolved one" {
|
||
// The regression this guards was nearly shipped. `maybeSnapNote` read
|
||
// `.date_at_or_before => |d| d, else => return`, so the moment `--since` began
|
||
// producing `.snapshot_add` the note went silent - including for a date far
|
||
// outside the snapshot series, which is the one case it exists to catch.
|
||
//
|
||
// Snapshot-anchored-and-resolved is not drift: the anchor is deliberately the
|
||
// commit that recorded that date. Snapshot-anchored-and-degraded is, because
|
||
// resolution fell back to whatever commit happened to precede the date.
|
||
const allocator = std.testing.allocator;
|
||
if (!test_git.available(allocator)) return;
|
||
|
||
var tmp = std.testing.tmpDir(.{});
|
||
defer tmp.cleanup();
|
||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||
const dir_len = try tmp.dir.realPathFile(std.testing.io, ".", &path_buf);
|
||
const dir = path_buf[0..dir_len];
|
||
|
||
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "portfolio.srf", .data = "security_type::cash,shares:num:1.00,account::A\n" });
|
||
try test_git.run(allocator, dir, null, &.{ "init", "-q" });
|
||
try test_git.run(allocator, dir, null, &.{ "config", "user.email", "t@e.com" });
|
||
try test_git.run(allocator, dir, null, &.{ "config", "user.name", "T" });
|
||
try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" });
|
||
try test_git.run(allocator, dir, null, &.{ "add", "." });
|
||
try test_git.run(allocator, dir, "2026-01-05T12:00:00", &.{ "commit", "-q", "-m", "one" });
|
||
|
||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
var env = try std.testing.environ.createMap(allocator);
|
||
defer env.deinit();
|
||
|
||
const repo = git.findRepo(std.testing.io, arena, &env, dir) catch return;
|
||
const far_future = zfin.Date.fromYmd(2026, 6, 1);
|
||
|
||
// Degraded: no `history/` at all, so `.snapshot_add` fell back to a commit five
|
||
// months earlier. `anchor == null` is the signal, and the note must fire.
|
||
try maybeSnapNote(std.testing.io, arena, &env, repo, .{ .snapshot_add = far_future }, "HEAD", "before", null);
|
||
|
||
// Resolved: an anchor came back, so the gap is intentional and there is
|
||
// nothing to report. Reaching the timestamp lookup at all would be the bug.
|
||
try maybeSnapNote(std.testing.io, arena, &env, repo, .{ .snapshot_add = far_future }, "HEAD", "before", .{
|
||
.commit = "HEAD",
|
||
.date = far_future,
|
||
});
|
||
}
|
||
|
||
test "resolveEndpoints: legacy dirty -> HEAD vs working copy" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
var env = try std.testing.environ.createMap(std.testing.allocator);
|
||
defer env.deinit();
|
||
const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };
|
||
|
||
const eps = try resolveEndpoints(std.testing.io, arena_state.allocator(), &env, repo, &.{repo.rel_path}, null, null, true, .verbose);
|
||
try std.testing.expectEqualStrings("HEAD", eps.range.before_rev);
|
||
try std.testing.expect(eps.range.after_rev == null);
|
||
try std.testing.expect(std.mem.indexOf(u8, eps.label, "working copy against HEAD") != null);
|
||
}
|
||
|
||
test "resolveEndpoints: legacy clean -> HEAD~1 vs HEAD" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
var env = try std.testing.environ.createMap(std.testing.allocator);
|
||
defer env.deinit();
|
||
const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };
|
||
|
||
const eps = try resolveEndpoints(std.testing.io, arena_state.allocator(), &env, repo, &.{repo.rel_path}, null, null, false, .verbose);
|
||
try std.testing.expectEqualStrings("HEAD~1", eps.range.before_rev);
|
||
try std.testing.expectEqualStrings("HEAD", eps.range.after_rev.?);
|
||
try std.testing.expect(std.mem.indexOf(u8, eps.label, "HEAD~1 against HEAD") != null);
|
||
}
|
||
|
||
test "short: long SHA truncates to 7 chars" {
|
||
// Works for both SHA-1 (40) and SHA-256 (64). Use a 40-char
|
||
// input as the common case; the function only cares that input
|
||
// is >= 7 chars.
|
||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||
try std.testing.expectEqualStrings("0123456", short(sha));
|
||
}
|
||
|
||
test "short: SHA-256 length also truncates to 7" {
|
||
// Forward-compat: same behavior regardless of hash algorithm.
|
||
const sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||
try std.testing.expectEqualStrings("0123456", short(sha));
|
||
}
|
||
|
||
test "short: short input returned as-is" {
|
||
try std.testing.expectEqualStrings("abc", short("abc"));
|
||
}
|
||
|
||
// ── matchTransfers tests ─────────────────────────────────────
|
||
//
|
||
// These exercise the transfer reclassification pipeline end-to-end
|
||
// via `computeReport(... .{ .transfer_log = &log, ... })`. Each test
|
||
// parses a small SRF fragment into a `TransactionLog`, wires it into
|
||
// `ReportOptions`, and asserts the resulting `Report.changes` kinds
|
||
// and per-account attribution.
|
||
|
||
test "diffTransferLogs: empty before, all records new" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const after = try transaction_log.parseTransactionLogFile(arena,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const new_records = try diffTransferLogs(arena, null, &after);
|
||
try std.testing.expectEqual(@as(usize, 2), new_records.len);
|
||
}
|
||
|
||
test "diffTransferLogs: identical before and after returns empty" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const before = try transaction_log.parseTransactionLogFile(arena,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\
|
||
);
|
||
const after = try transaction_log.parseTransactionLogFile(arena,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const new_records = try diffTransferLogs(arena, &before, &after);
|
||
try std.testing.expectEqual(@as(usize, 0), new_records.len);
|
||
}
|
||
|
||
test "diffTransferLogs: only the new record returned" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const before = try transaction_log.parseTransactionLogFile(arena,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\
|
||
);
|
||
const after = try transaction_log.parseTransactionLogFile(arena,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const new_records = try diffTransferLogs(arena, &before, &after);
|
||
try std.testing.expectEqual(@as(usize, 1), new_records.len);
|
||
try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, new_records[0].transfer.days);
|
||
}
|
||
|
||
test "diffTransferLogs: edited record treated as new" {
|
||
// User changed the `from` field on a previously-recorded transfer.
|
||
// Old form is in `before`, new form is in `after`. The new form
|
||
// doesn't equal anything in before -> returned as new. The old
|
||
// form is in before but not after -> silently dropped (its diff
|
||
// cycle is over).
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const before = try transaction_log.parseTransactionLogFile(arena,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Wrong Acct,to::Acct B,dest_lot::cash
|
||
\\
|
||
);
|
||
const after = try transaction_log.parseTransactionLogFile(arena,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const new_records = try diffTransferLogs(arena, &before, &after);
|
||
try std.testing.expectEqual(@as(usize, 1), new_records.len);
|
||
try std.testing.expectEqualStrings("Acct A", new_records[0].from);
|
||
}
|
||
|
||
test "diffTransferLogs: out-of-order records still match correctly" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const before = try transaction_log.parseTransactionLogFile(arena,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
|
||
\\
|
||
);
|
||
// After has the same two records but in reverse order.
|
||
const after = try transaction_log.parseTransactionLogFile(arena,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
|
||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const new_records = try diffTransferLogs(arena, &before, &after);
|
||
try std.testing.expectEqual(@as(usize, 0), new_records.len);
|
||
}
|
||
|
||
test "diffTransferLogs: back-dated record added on top of existing log" {
|
||
// Regression test for the original bug: user records a transfer
|
||
// months after it actually happened. The before-side log has
|
||
// some prior records; the after-side adds one with an old
|
||
// transfer::DATE. That new record must be returned even though
|
||
// its date predates everything in before.
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const before = try transaction_log.parseTransactionLogFile(arena,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-15,type::cash,amount:num:1000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\
|
||
);
|
||
const after = try transaction_log.parseTransactionLogFile(arena,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-15,type::cash,amount:num:1000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\transfer::2026-01-15,type::cash,amount:num:9999,from::Old Acct,to::New Acct,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const new_records = try diffTransferLogs(arena, &before, &after);
|
||
try std.testing.expectEqual(@as(usize, 1), new_records.len);
|
||
try std.testing.expectEqual(@as(f64, 9999), new_records[0].amount);
|
||
}
|
||
|
||
test "matchTransfers: cash-to-cash happy path" {
|
||
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();
|
||
|
||
// One cash lot appearing on Acct B (simulates a $5k cash top-up).
|
||
const before = [_]Lot{};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "cash", .shares = 5000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 3), .{
|
||
.transfer_log = tlog.transfers,
|
||
});
|
||
|
||
// The new_cash Change stays as new_cash in the display (we don't
|
||
// flip cash-side Changes because a single cash_delta can be
|
||
// drained by multiple records). A synthetic transfer_in Change
|
||
// is appended for Transfers-section display, and the $5k lands on
|
||
// the cash Change's `transfer_attributed` so `attributedValue()`
|
||
// reports the residual.
|
||
var n_new_cash: usize = 0;
|
||
var n_transfer_in: usize = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.new_cash => n_new_cash += 1,
|
||
.transfer_in => n_transfer_in += 1,
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 1), n_new_cash);
|
||
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
|
||
|
||
// Attribution on Acct B: $5k new_cash minus $5k attribution bucket = $0.
|
||
const t = report.account_totals.get("Acct B").?;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
|
||
}
|
||
|
||
test "matchTransfers: lot destination happy path" {
|
||
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);
|
||
|
||
// Brand new $8k stock lot on Acct B.
|
||
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::cash,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,
|
||
});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 8000.0), report.changes[0].transfer_attributed, 0.01);
|
||
|
||
// Acct B totals: $0 new_money (transfer_in contributes 0).
|
||
const t = report.account_totals.get("Acct B").?;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
|
||
}
|
||
|
||
test "matchTransfers: partial attribution - transfer smaller than lot" {
|
||
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);
|
||
|
||
// New $8k lot, but only $7k came from the transfer.
|
||
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::cash,amount:num:7000,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,
|
||
});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 7000.0), report.changes[0].transfer_attributed, 0.01);
|
||
// Residual = $8k - $7k = $1k, contributed to Acct B new_money.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1000.0), report.changes[0].attributedValue(), 0.01);
|
||
|
||
const t = report.account_totals.get("Acct B").?;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1000.0), t.new_money, 0.01);
|
||
}
|
||
|
||
test "matchTransfers: sweep - lot destination + cash residual, both 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", 1.0);
|
||
|
||
// $145,300 stock lot + $4,700 cash residual on Acct B.
|
||
const before = [_]Lot{};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "SYM", .shares = 145300, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 1.0, .account = "Acct B" },
|
||
.{ .symbol = "cash", .shares = 4700, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:145300,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||
\\transfer::2026-05-02,type::cash,amount:num:4700,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,
|
||
});
|
||
|
||
// Acct B new_money: $0 (both transfers fully attributed).
|
||
const t = report.account_totals.get("Acct B").?;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
|
||
|
||
// One transfer_in on the stock lot (kind flipped from new_stock).
|
||
var n_stock_transfer_in: usize = 0;
|
||
var n_cash_transfer_in: usize = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.transfer_in => {
|
||
if (c.security_type == .stock) n_stock_transfer_in += 1;
|
||
if (c.security_type == .cash) n_cash_transfer_in += 1;
|
||
},
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 1), n_stock_transfer_in);
|
||
// One synthetic cash transfer_in from the cash-dest matcher.
|
||
try std.testing.expectEqual(@as(usize, 1), n_cash_transfer_in);
|
||
}
|
||
|
||
test "matchTransfers: duplicate dest_lot emits unmatched" {
|
||
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" },
|
||
};
|
||
|
||
// Two records pointing at the same (account, symbol). First matches;
|
||
// second emits unmatched_transfer with "already claimed".
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||
\\transfer::2026-05-02,type::cash,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 "matchTransfers: missing dest_lot emits unmatched" {
|
||
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();
|
||
|
||
// No new lots in the diff at all.
|
||
const before = [_]Lot{};
|
||
const after = [_]Lot{};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,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,
|
||
});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.unmatched_transfer, report.changes[0].kind);
|
||
}
|
||
|
||
test "matchTransfers: amount exceeds lot value emits unmatched" {
|
||
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" },
|
||
};
|
||
|
||
// $10k transfer against $8k lot.
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,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,
|
||
});
|
||
|
||
// Lot stays as new_stock; unmatched record appended.
|
||
var n_new_stock: usize = 0;
|
||
var n_unmatched: usize = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.new_stock => n_new_stock += 1,
|
||
.unmatched_transfer => n_unmatched += 1,
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 1), n_new_stock);
|
||
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
|
||
}
|
||
|
||
test "matchTransfers: a transfer spent on securities before any snapshot is not new money" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const allocator = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
try prices.put("VOO", 100.0);
|
||
|
||
// The real 2026-08 shape, scaled down. $50k moved into an account and was
|
||
// invested the same week, so no snapshot ever contains the cash: the diff
|
||
// sees a new security lot plus the few dollars that did not get spent.
|
||
const before = [_]Lot{};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "VOO", .shares = 499, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 100.0, .account = "Acct B" },
|
||
.{ .symbol = "cash", .shares = 100, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||
.transfer_log = tlog.transfers,
|
||
});
|
||
|
||
// Nothing new entered the portfolio: $49,900 of VOO plus $100 of leftover
|
||
// cash is exactly the $50,000 that moved. Before this was handled, the
|
||
// purchase counted as a fresh contribution - the mechanism that reported
|
||
// $738,814 of contributions for a 401(k)-to-BrokerageLink move.
|
||
const t = report.account_totals.get("Acct B").?;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
|
||
|
||
// And it is not reported as a discrepancy either, because it is not one:
|
||
// the destination gained precisely what the record declared.
|
||
for (report.changes) |c| {
|
||
try std.testing.expect(c.kind != .unmatched_transfer);
|
||
}
|
||
|
||
// The purchase is attributed as internally funded rather than being
|
||
// dropped, so it still shows under "Internal purchases".
|
||
var funded: f64 = 0;
|
||
for (report.changes) |c| {
|
||
if (c.kind == .new_stock) funded += c.internal_funded;
|
||
}
|
||
try std.testing.expectApproxEqAbs(@as(f64, 49900.0), funded, 0.01);
|
||
}
|
||
|
||
test "matchTransfers: a short cash increase credits what arrived and flags the gap" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const allocator = arena_state.allocator();
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
|
||
// Only $3k cash showed up on Acct B, but the record wants $5k.
|
||
const before = [_]Lot{};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "cash", .shares = 3000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:5000,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,
|
||
});
|
||
|
||
// The $2k the destination never gained is still a real discrepancy.
|
||
var n_unmatched: usize = 0;
|
||
for (report.changes) |c| if (c.kind == .unmatched_transfer) {
|
||
n_unmatched += 1;
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
|
||
|
||
// But the $3k that DID arrive is transferred money, not new money.
|
||
// Previously the whole record was abandoned when the amounts disagreed,
|
||
// so a correct partial attribution was discarded and the $3k was reported
|
||
// as a fresh contribution - which it demonstrably is not, since a transfer
|
||
// record says where it came from.
|
||
const t = report.account_totals.get("Acct B").?;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
|
||
}
|
||
|
||
test "matchTransfers: same-day multi-cash records drain a single cash_delta" {
|
||
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();
|
||
|
||
// $5k cash showed up on Acct B.
|
||
const before = [_]Lot{};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "cash", .shares = 5000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
|
||
};
|
||
|
||
// Two records ($2k + $3k = $5k).
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:2000,from::Acct A,to::Acct B,dest_lot::cash
|
||
\\transfer::2026-05-02,type::cash,amount:num:3000,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,
|
||
});
|
||
|
||
// Both records should match (budget fully consumed); no unmatched.
|
||
var n_unmatched: usize = 0;
|
||
var n_transfer_in: usize = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.unmatched_transfer => n_unmatched += 1,
|
||
.transfer_in => n_transfer_in += 1,
|
||
else => {},
|
||
};
|
||
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
|
||
try std.testing.expectEqual(@as(usize, 2), n_transfer_in);
|
||
|
||
// Acct B new_money: $5k new_cash - $5k attribution = $0.
|
||
const t = report.account_totals.get("Acct B").?;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
|
||
}
|
||
|
||
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();
|
||
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
|
||
\\
|
||
);
|
||
|
||
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_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);
|
||
// The OLDX disposal is a security sale, so it reports as released
|
||
// funds under Internal purchases rather than as an anomaly under
|
||
// Flagged for review.
|
||
try std.testing.expect(std.mem.indexOf(u8, text, "Internal purchases") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, text, "OLDX") != 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
|
||
// narrowing the slice to records that should be considered for
|
||
// this diff cycle. A record dated weeks before any "diff window"
|
||
// pairs cleanly when passed to the matcher directly.
|
||
//
|
||
// This is the regression test for the back-dated-record-rejected
|
||
// bug: user adds `transfer::2026-01-15` to transaction_log.srf
|
||
// on 2026-05-04 to record a transfer that actually happened
|
||
// months ago. The matcher must pair it.
|
||
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-01-15,type::cash,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,
|
||
});
|
||
|
||
// Record paired despite the months-earlier date.
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind);
|
||
}
|
||
|
||
test "matchTransfers: null transfer_log is a no-op (backward compat)" {
|
||
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" },
|
||
};
|
||
|
||
// No transfer_log passed - baseline behavior with no reclassification.
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
||
try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
|
||
}
|
||
|
||
test "matchTransfers: attribution excludes transferred amount" {
|
||
// End-to-end: run through the same computeReport path that
|
||
// `summarizeAttribution` consumes, and verify the attribution
|
||
// line would be $0 for a fully-transferred lot.
|
||
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::cash,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,
|
||
});
|
||
|
||
// Replicate summarizeAttribution's logic directly. Uses
|
||
// `attributedValue()` on the new-side kinds, exactly as the real
|
||
// summary does: the per-Change residual IS the subtraction, so there
|
||
// is nothing further to net off. An earlier version of this test
|
||
// summed raw `value()` and then subtracted a separate per-account
|
||
// bucket - the superseded mechanism - which meant it reproduced an
|
||
// old formula rather than exercising the shipped one.
|
||
var new_contributions: f64 = 0;
|
||
var drip: f64 = 0;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(),
|
||
.new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
|
||
.partial_transfer_in => new_contributions += c.attributedValue(),
|
||
else => {},
|
||
};
|
||
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_contributions, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), drip, 0.01);
|
||
}
|
||
|
||
// ── collectUnmatchedLargeLots tests ──────────────────────────
|
||
//
|
||
// These exercise the audit large-lot filter by building a `Report`
|
||
// via `computeReport` (with or without a transfer log) and feeding
|
||
// its changes directly to `collectUnmatchedLargeLots`. That skips
|
||
// the git + IO plumbing of `findUnmatchedLargeLots` while still
|
||
// covering the classification path the production code uses.
|
||
|
||
test "collectUnmatchedLargeLots: below threshold is 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();
|
||
try prices.put("SYM", 100.0);
|
||
|
||
const before = [_]Lot{};
|
||
// $5k lot - under the $10k threshold used by audit.
|
||
const after = [_]Lot{
|
||
.{ .symbol = "SYM", .shares = 50, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct A" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||
|
||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||
}
|
||
|
||
test "collectUnmatchedLargeLots: unmatched large stock lot surfaces" {
|
||
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", 500.0);
|
||
|
||
const before = [_]Lot{};
|
||
// $50k stock lot, no transfer log, should surface.
|
||
const after = [_]Lot{
|
||
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), lots.len);
|
||
try std.testing.expectEqualStrings("Acct A", lots[0].account);
|
||
try std.testing.expectEqualStrings("SYM", lots[0].symbol);
|
||
try std.testing.expectEqual(LotType.stock, lots[0].security_type);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01);
|
||
try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, lots[0].open_date.days);
|
||
}
|
||
|
||
test "collectUnmatchedLargeLots: per-account threshold suppresses one account, default flags the other" {
|
||
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("ESPPSYM", 300.0);
|
||
try prices.put("BRKSYM", 300.0);
|
||
|
||
const before = [_]Lot{};
|
||
// Two new $30k lots in different accounts.
|
||
const after = [_]Lot{
|
||
.{ .symbol = "ESPPSYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 300, .account = "Sample ESPP" },
|
||
.{ .symbol = "BRKSYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 300, .account = "Sample Brokerage" },
|
||
};
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||
|
||
// Sanity: with no account_map, both $30k lots clear the $10k
|
||
// default and surface. This isolates the override as the cause of
|
||
// the difference below.
|
||
const both = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||
try std.testing.expectEqual(@as(usize, 2), both.len);
|
||
|
||
// ESPP raises its own threshold to $50k (routine large accruals);
|
||
// Sample Brokerage leaves it at the default. Now only the
|
||
// brokerage lot surfaces - the $30k ESPP lot is below its $50k bar.
|
||
var am = try analysis.parseAccountsFile(allocator,
|
||
\\#!srfv1
|
||
\\account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
);
|
||
defer am.deinit();
|
||
|
||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, &am);
|
||
try std.testing.expectEqual(@as(usize, 1), lots.len);
|
||
try std.testing.expectEqualStrings("Sample Brokerage", lots[0].account);
|
||
try std.testing.expectEqualStrings("BRKSYM", lots[0].symbol);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), lots[0].value, 0.01);
|
||
}
|
||
|
||
test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" {
|
||
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 = 50_000, .open_date = Date.fromYmd(2026, 5, 10), .open_price = 1.0, .security_type = .cash, .account = "Acct A" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{});
|
||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), lots.len);
|
||
try std.testing.expectEqual(LotType.cash, lots[0].security_type);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01);
|
||
}
|
||
|
||
test "collectUnmatchedLargeLots: matched via transfer log is 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();
|
||
try prices.put("SYM", 500.0);
|
||
|
||
const before = [_]Lot{};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
|
||
};
|
||
|
||
// Transfer log fully covers the lot -> kind flips to transfer_in.
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03
|
||
\\
|
||
);
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||
.transfer_log = tlog.transfers,
|
||
});
|
||
|
||
// Sanity: the lot should have been reclassified.
|
||
try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind);
|
||
|
||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||
}
|
||
|
||
test "collectUnmatchedLargeLots: cash-destination matched is silent" {
|
||
// Regression for the user-visible bug: a $73,158.33 cash lot on
|
||
// Sample Trust funded by a transfer record dated 2026-05-20 was
|
||
// surfacing in audit's "Large new lots - confirm source" because
|
||
// the cash matcher doesn't flip the original `new_cash` Change's
|
||
// kind (the attribution rides on `transfer_attributed` instead).
|
||
// Without subtracting that attribution, the audit filter
|
||
// re-flagged a lot that's already explained.
|
||
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{
|
||
.{ .security_type = .cash, .shares = 73158.33, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-20,type::cash,amount:num:73158.33,from::Sample Source,to::Sample Trust,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{
|
||
.transfer_log = tlog.transfers,
|
||
});
|
||
|
||
// Cash-dest matching does NOT flip the original new_cash Change
|
||
// (a single delta can be drained by multiple records). The
|
||
// matcher records the attributed amount in
|
||
// `transfer_attributed` instead.
|
||
var saw_new_cash = false;
|
||
var saw_synthetic_transfer = false;
|
||
for (report.changes) |c| switch (c.kind) {
|
||
.new_cash => saw_new_cash = true,
|
||
.transfer_in => saw_synthetic_transfer = true,
|
||
else => {},
|
||
};
|
||
try std.testing.expect(saw_new_cash);
|
||
try std.testing.expect(saw_synthetic_transfer);
|
||
// The attribution lands on the consumed cash Change, so the residual - not a
|
||
// separate per-account bucket - is what every consumer sees. Assert it there,
|
||
// which is also the value the audit filter below acts on.
|
||
var residual: f64 = 0;
|
||
for (report.changes) |c| {
|
||
if (c.kind == .new_cash) residual += c.attributedValue();
|
||
}
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), residual, 0.01);
|
||
|
||
// The audit filter must see a fully-attributed lot and stay quiet.
|
||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||
}
|
||
|
||
test "collectUnmatchedLargeLots: cash-destination partial match surfaces residual only" {
|
||
// A $50K cash lot with a $30K transfer attributed against it -
|
||
// the residual $20K is "new contribution" and SHOULD surface
|
||
// (above the $10K threshold). The filter reports the residual,
|
||
// not the gross.
|
||
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{
|
||
.{ .security_type = .cash, .shares = 50000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-20,type::cash,amount:num:30000,from::Sample Source,to::Sample Trust,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{
|
||
.transfer_log = tlog.transfers,
|
||
});
|
||
|
||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||
try std.testing.expectEqual(@as(usize, 1), lots.len);
|
||
try std.testing.expectEqual(@as(f64, 20000.0), lots[0].value);
|
||
}
|
||
|
||
test "collectUnmatchedLargeLots: cash-destination partial below threshold is silent" {
|
||
// A $15K cash lot with a $10K transfer attributed -> residual
|
||
// $5K, 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{};
|
||
const after = [_]Lot{
|
||
.{ .security_type = .cash, .shares = 15000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-20,type::cash,amount:num:10000,from::Sample Source,to::Sample Trust,dest_lot::cash
|
||
\\
|
||
);
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{
|
||
.transfer_log = tlog.transfers,
|
||
});
|
||
|
||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||
}
|
||
|
||
test "collectUnmatchedLargeLots: no new lots -> empty result" {
|
||
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), .{});
|
||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||
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, null);
|
||
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, null);
|
||
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, null);
|
||
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
|
||
// `is_new_side` check). That's the correct behavior: the
|
||
// unrecorded portion on a partial is typically small (pre-
|
||
// existing cash) and already has an explicit transfer record
|
||
// acknowledging the large movement. Surfacing it again would
|
||
// double-nag.
|
||
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", 500.0);
|
||
|
||
const before = [_]Lot{};
|
||
// $50k lot, $45k from a transfer -> partial_transfer_in with
|
||
// $5k residual. Residual is below $10k threshold anyway, but
|
||
// even if it weren't, the filter skips partial_transfer_in.
|
||
const after = [_]Lot{
|
||
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
|
||
};
|
||
|
||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||
\\#!srfv1
|
||
\\transfer::2026-05-02,type::cash,amount:num:45000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03
|
||
\\
|
||
);
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||
.transfer_log = tlog.transfers,
|
||
});
|
||
|
||
try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind);
|
||
|
||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||
}
|
||
|
||
test "shortSha: HEAD passes through unchanged" {
|
||
try std.testing.expectEqualStrings("HEAD", shortSha("HEAD"));
|
||
try std.testing.expectEqualStrings("HEAD~", shortSha("HEAD~"));
|
||
try std.testing.expectEqualStrings("HEAD~3", shortSha("HEAD~3"));
|
||
}
|
||
|
||
test "shortSha: long SHA truncates to 7 chars" {
|
||
try std.testing.expectEqualStrings("abcdef0", shortSha("abcdef0123456789"));
|
||
try std.testing.expectEqualStrings("a1b2c3d", shortSha("a1b2c3d4e5f6789012345"));
|
||
}
|
||
|
||
test "shortSha: short input returned as-is" {
|
||
try std.testing.expectEqualStrings("abc", shortSha("abc"));
|
||
try std.testing.expectEqualStrings("abcdefg", shortSha("abcdefg")); // exactly 7
|
||
try std.testing.expectEqualStrings("", shortSha(""));
|
||
}
|
||
|
||
test "specDisplayString: null yields '(unset)'" {
|
||
var buf: [10]u8 = undefined;
|
||
try std.testing.expectEqualStrings("(unset)", specDisplayString(null, &buf));
|
||
}
|
||
|
||
test "specDisplayString: working_copy yields 'working'" {
|
||
var buf: [10]u8 = undefined;
|
||
try std.testing.expectEqualStrings("working", specDisplayString(.{ .working_copy = {} }, &buf));
|
||
}
|
||
|
||
test "specDisplayString: git_ref returns ref verbatim" {
|
||
var buf: [10]u8 = undefined;
|
||
try std.testing.expectEqualStrings("HEAD", specDisplayString(.{ .git_ref = "HEAD" }, &buf));
|
||
try std.testing.expectEqualStrings("main", specDisplayString(.{ .git_ref = "main" }, &buf));
|
||
}
|
||
|
||
test "specDisplayString: date_at_or_before formats date YYYY-MM-DD" {
|
||
var buf: [10]u8 = undefined;
|
||
const d = Date.fromYmd(2024, 3, 15);
|
||
try std.testing.expectEqualStrings("2024-03-15", specDisplayString(.{ .date_at_or_before = d }, &buf));
|
||
}
|
||
|
||
test "specLabel: null spec returns resolved ref dup'd" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const result = try specLabel(arena, null, "abc1234");
|
||
try std.testing.expectEqualStrings("abc1234", result);
|
||
}
|
||
|
||
test "specLabel: git_ref returns ref dup'd" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const result = try specLabel(arena, .{ .git_ref = "main" }, "ignored");
|
||
try std.testing.expectEqualStrings("main", result);
|
||
}
|
||
|
||
test "specLabel: date renders 'commit at-or-before YYYY-MM-DD'" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const d = Date.fromYmd(2024, 3, 15);
|
||
const result = try specLabel(arena, .{ .date_at_or_before = d }, "ignored");
|
||
try std.testing.expectEqualStrings("commit at-or-before 2024-03-15", result);
|
||
}
|
||
|
||
test "specLabel: working_copy literal" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const result = try specLabel(arena, .{ .working_copy = {} }, "ignored");
|
||
try std.testing.expectEqualStrings("working copy", result);
|
||
}
|
||
|
||
test "specLabelAfter: null spec + non-null resolved_ref returns resolved_ref" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const result = try specLabelAfter(arena, null, "HEAD");
|
||
try std.testing.expectEqualStrings("HEAD", result);
|
||
}
|
||
|
||
test "specLabelAfter: null spec + null resolved_ref returns 'working copy'" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const result = try specLabelAfter(arena, null, null);
|
||
try std.testing.expectEqualStrings("working copy", result);
|
||
}
|
||
|
||
test "specLabelAfter: spec set + null resolved_ref defaults to 'working'" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const result = try specLabelAfter(arena, .{ .working_copy = {} }, null);
|
||
try std.testing.expectEqualStrings("working copy", result);
|
||
}
|
||
|
||
test "buildLabel: no date window, dirty -> 'Comparing working copy against HEAD'" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
|
||
const result = try buildLabel(arena, range, null, null, true);
|
||
try std.testing.expectEqualStrings("Comparing working copy against HEAD", result);
|
||
}
|
||
|
||
test "buildLabel: no date window, clean -> HEAD~1 against HEAD" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
|
||
const result = try buildLabel(arena, range, null, null, false);
|
||
try std.testing.expectEqualStrings("Working tree clean - comparing HEAD~1 against HEAD", result);
|
||
}
|
||
|
||
test "buildLabel: --since only, dirty -> against working copy" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
|
||
const since = Date.fromYmd(2024, 3, 15);
|
||
const result = try buildLabel(arena, range, since, null, true);
|
||
try std.testing.expect(std.mem.indexOf(u8, result, "abc1234") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, result, "working copy") != null);
|
||
}
|
||
|
||
test "buildLabel: --since only, clean -> against HEAD" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
|
||
const since = Date.fromYmd(2024, 3, 15);
|
||
const result = try buildLabel(arena, range, since, null, false);
|
||
try std.testing.expect(std.mem.indexOf(u8, result, "against HEAD") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
|
||
}
|
||
|
||
test "buildLabel: --since + --until renders both dates and short SHAs" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = "def4567890123" };
|
||
const since = Date.fromYmd(2024, 1, 15);
|
||
const until = Date.fromYmd(2024, 3, 15);
|
||
const result = try buildLabel(arena, range, since, until, false);
|
||
try std.testing.expect(std.mem.indexOf(u8, result, "2024-01-15") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, result, "abc1234") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, result, "def4567") != null);
|
||
}
|
||
|
||
test "buildLabelFromSpecs: both date specs -> falls through to buildLabel" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const range = git.CommitRange{ .before_rev = "aaaaaaa1234567", .after_rev = "bbbbbbb1234567" };
|
||
const before_d = Date.fromYmd(2024, 1, 15);
|
||
const after_d = Date.fromYmd(2024, 3, 15);
|
||
const result = try buildLabelFromSpecs(
|
||
arena,
|
||
range,
|
||
.{ .date_at_or_before = before_d },
|
||
.{ .date_at_or_before = after_d },
|
||
false,
|
||
);
|
||
// Date-form path: uses buildLabel formatting
|
||
try std.testing.expect(std.mem.indexOf(u8, result, "2024-01-15") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
|
||
}
|
||
|
||
test "buildLabelFromSpecs: non-date spec -> '<before> vs <after>' format" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const range = git.CommitRange{ .before_rev = "main", .after_rev = "feature" };
|
||
const result = try buildLabelFromSpecs(
|
||
arena,
|
||
range,
|
||
.{ .git_ref = "main" },
|
||
.{ .git_ref = "feature" },
|
||
false,
|
||
);
|
||
try std.testing.expectEqualStrings("main vs feature", result);
|
||
}
|
||
|
||
test "buildLabelFromSpecs: working_copy after -> 'working copy' literal" {
|
||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
|
||
const result = try buildLabelFromSpecs(
|
||
arena,
|
||
range,
|
||
.{ .git_ref = "HEAD~1" },
|
||
.{ .working_copy = {} },
|
||
true,
|
||
);
|
||
try std.testing.expectEqualStrings("HEAD~1 vs working copy", result);
|
||
}
|
||
|
||
test "printChangeLine: stock change shows shares × price = value" {
|
||
var buf: [256]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const c = Change{
|
||
.kind = .new_stock,
|
||
.symbol = "AAPL",
|
||
.account = "Roth",
|
||
.security_type = .stock,
|
||
.delta_shares = 10,
|
||
.unit_value = 150.0,
|
||
};
|
||
try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
|
||
const out = w.buffered();
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "Roth") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "shares") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "$150.00") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "$1,500.00") != null);
|
||
}
|
||
|
||
test "printChangeLine: cash change shows value only (no shares × price)" {
|
||
var buf: [256]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const c = Change{
|
||
.kind = .new_cash,
|
||
.symbol = "CASH",
|
||
.account = "Brokerage",
|
||
.security_type = .cash,
|
||
.delta_shares = 1000,
|
||
.unit_value = 1.0,
|
||
};
|
||
try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
|
||
const out = w.buffered();
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "CASH") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "Brokerage") != null);
|
||
// cash shouldn't show "shares ×"
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "shares ×") == null);
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "$1,000.00") != null);
|
||
}
|
||
|
||
test "printChangeLine: empty account shown as '(no account)'" {
|
||
var buf: [256]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const c = Change{
|
||
.kind = .new_stock,
|
||
.symbol = "VTI",
|
||
.account = "",
|
||
.security_type = .stock,
|
||
.delta_shares = 5,
|
||
.unit_value = 200.0,
|
||
};
|
||
try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
|
||
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "(no account)") != null);
|
||
}
|
||
|
||
test "printSummaryCell: zero value renders muted dash" {
|
||
var buf: [128]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
try printSummaryCell(&w, "Drip", 0, false);
|
||
const out = w.buffered();
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "Drip") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "-") != null);
|
||
}
|
||
|
||
test "printSummaryCell: nonzero value renders dollar amount" {
|
||
var buf: [128]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
try printSummaryCell(&w, "Drip", 250.50, false);
|
||
const out = w.buffered();
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "Drip") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "$250.50") != null);
|
||
}
|
||
|
||
test "printSection: emits title with header style" {
|
||
var buf: [256]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
try printSection(&w, "Contributions", false, cli.CLR_POSITIVE);
|
||
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "Contributions") != null);
|
||
}
|
||
|
||
test "printNone: emits muted '(none)' line" {
|
||
var buf: [128]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
try printNone(&w, false, cli.CLR_MUTED);
|
||
const out = w.buffered();
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "none") != null or std.mem.indexOf(u8, out, "None") != null);
|
||
}
|
||
|
||
test "printTotalLine: emits label and dollar amount" {
|
||
var buf: [256]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
try printTotalLine(&w, "Total:", 12_345.67, false, cli.CLR_POSITIVE);
|
||
const out = w.buffered();
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "Total") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "$12,345.67") != null);
|
||
}
|
||
|
||
test "printPriceOnlyLine: shows old -> new price" {
|
||
var buf: [256]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const c = Change{
|
||
.kind = .price_only,
|
||
.symbol = "VTI",
|
||
.account = "Roth",
|
||
.security_type = .stock,
|
||
.old_price = 100.0,
|
||
.new_price = 110.0,
|
||
};
|
||
try printPriceOnlyLine(&w, c, false, cli.CLR_MUTED);
|
||
const out = w.buffered();
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "$100.00") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, out, "$110.00") != null);
|
||
}
|
||
|
||
test "printChangeLine: no ANSI when color=false" {
|
||
var buf: [256]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const c = Change{
|
||
.kind = .new_stock,
|
||
.symbol = "AAPL",
|
||
.account = "Roth",
|
||
.security_type = .stock,
|
||
.delta_shares = 10,
|
||
.unit_value = 150.0,
|
||
};
|
||
try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
|
||
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null);
|
||
}
|
||
|
||
test "printReport: empty report says no changes" {
|
||
var buf: [512]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
var account_totals = std.StringHashMap(Report.AccountTotal).init(testing.allocator);
|
||
defer account_totals.deinit();
|
||
var changes = [_]Change{};
|
||
const report = Report{
|
||
.changes = changes[0..],
|
||
.account_totals = account_totals,
|
||
};
|
||
try printReport(&w, &report, "portfolio.srf", false);
|
||
const out = w.buffered();
|
||
try testing.expect(std.mem.indexOf(u8, out, "No changes detected.") != null);
|
||
}
|
||
|
||
test "printReport: full report renders every section and sub-printer" {
|
||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
// One change of (nearly) every kind, exercising every section and
|
||
// each line-printer. Values are arbitrary but internally consistent
|
||
// for the CD-interest recompute (cash_delta - face = implied interest).
|
||
var changes = [_]Change{
|
||
.{ .kind = .new_stock, .symbol = "AAPL", .account = "Roth", .security_type = .stock, .delta_shares = 10, .unit_value = 150 },
|
||
.{ .kind = .new_cash, .symbol = "CASH", .account = "Roth", .security_type = .cash, .delta_shares = 2000, .unit_value = 1 },
|
||
.{ .kind = .new_drip_lot, .symbol = "VTI", .account = "Roth", .security_type = .stock, .delta_shares = 2, .unit_value = 200 },
|
||
.{ .kind = .drip_confirmed, .symbol = "SCHD", .account = "Roth", .security_type = .stock, .delta_shares = 1, .unit_value = 75 },
|
||
.{ .kind = .rollup_delta, .symbol = "VOO", .account = "Brokerage", .security_type = .stock, .delta_shares = 0.5, .unit_value = 400 },
|
||
.{ .kind = .cd_matured, .symbol = "CD1", .account = "CD Acct", .security_type = .cd, .face_value = 10000, .maturity_date = Date.fromYmd(2026, 5, 1) },
|
||
.{ .kind = .cash_delta, .symbol = "CASH", .account = "CD Acct", .security_type = .cash, .delta_shares = 10500, .unit_value = 1 },
|
||
.{ .kind = .cash_contribution, .symbol = "CASH", .account = "HSA", .security_type = .cash, .delta_shares = 300, .unit_value = 1 },
|
||
.{ .kind = .transfer_in, .symbol = "VTI", .account = "Acct B", .security_type = .stock, .delta_shares = 5, .unit_value = 100, .transfer_attributed = 500, .transfer_from = "Acct A", .transfer_date = Date.fromYmd(2026, 5, 2), .transfer_note = "rollover" },
|
||
.{ .kind = .transfer_out, .symbol = "", .account = "Acct A", .security_type = .cash, .transfer_attributed = 500, .transfer_date = Date.fromYmd(2026, 5, 2) },
|
||
.{ .kind = .partial_transfer_in, .symbol = "SPY", .account = "Acct B", .security_type = .stock, .delta_shares = 10, .unit_value = 100, .transfer_attributed = 600, .transfer_from = "Acct A", .transfer_date = Date.fromYmd(2026, 5, 2) },
|
||
.{ .kind = .price_only, .symbol = "VTI", .account = "Roth", .security_type = .stock, .old_price = 100, .new_price = 110 },
|
||
.{ .kind = .lot_edited, .symbol = "BND", .account = "Trust", .security_type = .stock },
|
||
.{ .kind = .flagged, .symbol = "XYZ", .account = "Roth", .security_type = .stock, .detail = "manual edit" },
|
||
.{ .kind = .lot_removed, .symbol = "OLD", .account = "Brokerage", .security_type = .stock, .face_value = 2500 },
|
||
.{ .kind = .drip_negative, .symbol = "ABC", .account = "Roth", .security_type = .stock, .delta_shares = -1, .unit_value = 50 },
|
||
.{ .kind = .unmatched_transfer, .symbol = "", .account = "Acct C", .security_type = .cash, .transfer_attributed = 750, .transfer_from = "Acct D", .transfer_date = Date.fromYmd(2026, 5, 3), .transfer_note = "unmatched wire" },
|
||
};
|
||
|
||
var account_totals = std.StringHashMap(Report.AccountTotal).init(arena);
|
||
try account_totals.put("Roth", .{ .new_money = 3800, .drip_confirmed = 475, .rollup = 0, .cash_delta = 0 });
|
||
try account_totals.put("CD Acct", .{ .cash_delta = 10500 });
|
||
try account_totals.put("", .{}); // exercises "(no account)" label + all-zero summary cells
|
||
|
||
const report = Report{
|
||
.changes = changes[0..],
|
||
.account_totals = account_totals,
|
||
};
|
||
|
||
var buf: [16384]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
try printReport(&w, &report, "portfolio.srf (+1 more)", false);
|
||
const out = w.buffered();
|
||
|
||
// Section headers.
|
||
try testing.expect(std.mem.indexOf(u8, out, "Portfolio contributions report") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "== New contributions / purchases ==") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "== DRIP (confirmed") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "== Rollup share deltas") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "== CD events ==") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "== Cash deltas") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "== Transfers (matched") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "== Price-only updates") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "== Lot edits") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "== Flagged for review ==") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "== Summary by account ==") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "Grand total:") != null);
|
||
|
||
// Sub-printer content.
|
||
try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); // printChangeLine
|
||
try testing.expect(std.mem.indexOf(u8, out, "matured") != null); // printCdLine
|
||
try testing.expect(std.mem.indexOf(u8, out, "implied interest") != null); // CD interest line
|
||
try testing.expect(std.mem.indexOf(u8, out, "Implied interest captured") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "Acct A -> Acct B") != null); // printTransferLine
|
||
try testing.expect(std.mem.indexOf(u8, out, "rollover") != null); // transfer note
|
||
try testing.expect(std.mem.indexOf(u8, out, "rest from transfer") != null); // printPartialTransferLine
|
||
try testing.expect(std.mem.indexOf(u8, out, "price ") != null); // printPriceOnlyLine
|
||
try testing.expect(std.mem.indexOf(u8, out, "manual edit") != null); // printFlaggedLine flagged
|
||
try testing.expect(std.mem.indexOf(u8, out, "sold at mark") != null); // printCollapsedSales, no close_price
|
||
try testing.expect(std.mem.indexOf(u8, out, "Transfer 2026-05-03") != null); // printUnmatchedTransferLine
|
||
try testing.expect(std.mem.indexOf(u8, out, "unmatched wire") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "(no account)") != null); // summary no-account label
|
||
try testing.expect(std.mem.indexOf(u8, out, "may include CD maturity") != null); // printCashDeltaLine hint
|
||
|
||
// Color path: a second render with color=true emits ANSI escapes.
|
||
var cbuf: [16384]u8 = undefined;
|
||
var cw: std.Io.Writer = .fixed(&cbuf);
|
||
try printReport(&cw, &report, "portfolio.srf", true);
|
||
try testing.expect(std.mem.indexOf(u8, cw.buffered(), "\x1b[") != null);
|
||
}
|
||
|
||
test "collectUnmatchedLargeLots: a large cash_contribution surfaces, undated, alongside the rest" {
|
||
// Regression. A positive balance change on a `cash_is_contribution`
|
||
// account is reclassified to `cash_contribution`, which carries no
|
||
// open_date. That returned error.MissingOpenDate, the caller turned
|
||
// it into "no section", and audit's entire "Large new lots" list -
|
||
// including the unrelated stock lot below - silently vanished.
|
||
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();
|
||
|
||
var am = try analysis.parseAccountsFile(
|
||
allocator,
|
||
"#!srfv1\naccount::Sample IRA,tax_type::traditional,cash_is_contribution:bool:true\n",
|
||
);
|
||
const before = [_]Lot{
|
||
.{ .symbol = "cash", .shares = 10_000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
|
||
};
|
||
const after = [_]Lot{
|
||
.{ .symbol = "cash", .shares = 60_000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
|
||
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2026, 5, 10), .open_price = 250.0, .account = "Sample IRA" },
|
||
};
|
||
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{ .account_map = &am });
|
||
// Precondition: the balance change really was reclassified.
|
||
var saw_contribution = false;
|
||
for (report.changes) |c| {
|
||
if (c.kind == .cash_contribution) saw_contribution = true;
|
||
}
|
||
try std.testing.expect(saw_contribution);
|
||
|
||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||
try std.testing.expectEqual(@as(usize, 2), lots.len);
|
||
var cash_seen = false;
|
||
var stock_seen = false;
|
||
for (lots) |l| {
|
||
switch (l.security_type) {
|
||
.cash => {
|
||
cash_seen = true;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), l.value, 0.01);
|
||
try std.testing.expect(l.open_date.eql(Date.epoch));
|
||
},
|
||
.stock => stock_seen = true,
|
||
else => {},
|
||
}
|
||
}
|
||
try std.testing.expect(cash_seen and stock_seen);
|
||
}
|
||
|
||
test "computeReport: an expired option recorded with a close afterwards is still a sale" {
|
||
// Why the close transition tests "sold", not "ended": the option
|
||
// ended at maturity on BOTH sides, so an ended-based test would see
|
||
// no transition and the documented record-the-close-after-expiry
|
||
// workflow would emit 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 opt: Lot = .{ .symbol = "AAPL 06/20/2025 200.00 C", .shares = -2, .open_date = Date.fromYmd(2025, 1, 15), .open_price = 12.5, .security_type = .option, .maturity_date = Date.fromYmd(2025, 6, 20), .underlying = "AAPL", .strike = 200, .account = "Sample Brokerage" };
|
||
var closed = opt;
|
||
closed.close_date = Date.fromYmd(2025, 6, 20);
|
||
closed.close_price = 0;
|
||
|
||
const before = [_]Lot{opt};
|
||
const after = [_]Lot{closed};
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2025, 7, 1), .{});
|
||
var saw_close = false;
|
||
for (report.changes) |c| {
|
||
if (c.kind == .position_closed) saw_close = true;
|
||
}
|
||
try std.testing.expect(saw_close);
|
||
}
|
||
|
||
test "computeReport: a close_date that hasn't arrived is not yet a sale" {
|
||
// The other side of the same definition. The lint flags a future
|
||
// close_date; until it arrives, the lot is held and no sale is booked.
|
||
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 lot: Lot = .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 200, .account = "Sample Brokerage" };
|
||
var later = lot;
|
||
later.close_date = Date.fromYmd(2062, 3, 14);
|
||
later.close_price = 300;
|
||
|
||
const before = [_]Lot{lot};
|
||
const after = [_]Lot{later};
|
||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{});
|
||
for (report.changes) |c| try std.testing.expect(c.kind != .position_closed);
|
||
}
|