contributions: track matured CDs properly
This commit is contained in:
parent
c020f92449
commit
c463d94ea5
3 changed files with 360 additions and 13 deletions
|
|
@ -54,9 +54,8 @@ rather than counting toward the total:
|
|||
alongside the account's cash going down.
|
||||
- **Reallocating -- selling one holding to buy another in the same
|
||||
account.** The sale's proceeds offset the repurchase.
|
||||
- **A CD paying out.** A CD that matures or is redeemed early, and is
|
||||
then closed or removed, counts like a sale: rolling it into a new CD,
|
||||
or buying with the payout, isn't new money.
|
||||
- **A CD paying out.** A CD's payout counts like a sale: rolling it
|
||||
into a new CD, or buying with the payout, isn't new money.
|
||||
|
||||
A sale is valued at `close_price` when you record one (see
|
||||
[`portfolio.srf`](../config/portfolio-srf.md)), which is what the sale
|
||||
|
|
@ -88,10 +87,16 @@ funded anything, and are treated accordingly. On an account marked
|
|||
`cash_is_contribution::true` they also cancel that account's cash
|
||||
credit, since the sale is not new money even though cash arrived.
|
||||
|
||||
Record a CD's close -- or remove it -- in the same commit as its
|
||||
payout. The proceeds land in whichever window the CD leaves the file,
|
||||
so removing it months later hands its face value to that later window,
|
||||
where it can hide a real contribution.
|
||||
A CD that matures pays out in the window containing its maturity date
|
||||
-- between the dates of the two commits being compared, or today for
|
||||
uncommitted changes -- whether you close it,
|
||||
delete it, archive it to `portfolio_closed.srf`, or leave it where it
|
||||
is. Closing or removing it in a later window changes nothing. A CD
|
||||
redeemed before maturity pays out in the window you close or remove it.
|
||||
|
||||
On an account marked `cash_is_contribution::true`, update its cash
|
||||
balance in that same window: a payout that reaches the cash line a
|
||||
commit later reads as new money there.
|
||||
|
||||
Movement **between** accounts is a different matter -- zfin cannot tell
|
||||
it from a contribution, so declare it in
|
||||
|
|
|
|||
|
|
@ -661,6 +661,7 @@ fn prepareReport(
|
|||
.{
|
||||
.account_map = if (account_map_opt) |*am| am else null,
|
||||
.transfer_log = new_records,
|
||||
.window = diffWindow(io, arena, env, repo.root, endpoints.range, as_of),
|
||||
},
|
||||
) catch {
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n");
|
||||
|
|
@ -684,6 +685,33 @@ fn prepareReport(
|
|||
/// a hard error.
|
||||
const Verbosity = enum { verbose, silent };
|
||||
|
||||
/// The dates the diff's two sides were taken, from the commits'
|
||||
/// committer timestamps; a working-copy after side is `as_of` (today).
|
||||
/// Null when a timestamp can't be read, which falls back to the
|
||||
/// window-free behavior - worth a debug line, not a failed report.
|
||||
fn diffWindow(
|
||||
io: std.Io,
|
||||
arena: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
root: []const u8,
|
||||
range: git.CommitRange,
|
||||
as_of: Date,
|
||||
) ?Window {
|
||||
const log = std.log.scoped(.contributions);
|
||||
const start = git.commitDate(io, arena, env, root, range.before_rev) catch |err| {
|
||||
log.debug("no diff window: before-commit date unavailable ({t})", .{err});
|
||||
return null;
|
||||
};
|
||||
const end: Date = if (range.after_rev) |rev|
|
||||
git.commitDate(io, arena, env, root, rev) catch |err| {
|
||||
log.debug("no diff window: after-commit date unavailable ({t})", .{err});
|
||||
return null;
|
||||
}
|
||||
else
|
||||
as_of;
|
||||
return .{ .start = start, .end = end };
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
@ -890,10 +918,7 @@ fn maybeSnapNote(
|
|||
// 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);
|
||||
const commit_date = git.commitDate(io, arena, env, repo.root, resolved_ref) catch return;
|
||||
if (!commit_date.lessThan(requested_date)) return;
|
||||
|
||||
const gap_days = requested_date.days - commit_date.days;
|
||||
|
|
@ -2219,8 +2244,49 @@ const ReportOptions = struct {
|
|||
/// See `diffTransferLogs` and the prepareReport pipeline for
|
||||
/// how the production caller assembles the slice.
|
||||
transfer_log: ?[]const transaction_log.TransferRecord = null,
|
||||
/// The dates the two sides of the diff were taken: the before
|
||||
/// commit's date and the after commit's (or today, for the working
|
||||
/// copy). `prepareReport` fills it from the commit timestamps.
|
||||
///
|
||||
/// It is what lets a CD's maturity date mean something. The lots
|
||||
/// alone say only "this CD matures Mar 1", identically on both
|
||||
/// sides, so a CD that matured but was left in the file - or
|
||||
/// archived unchanged - showed no change and its payout funded
|
||||
/// nothing. With the window, the payout lands in exactly the one
|
||||
/// window that contains the maturity date. See `cdMaturity`.
|
||||
///
|
||||
/// Null (tests, or a timestamp lookup that failed) keeps the
|
||||
/// window-free behavior.
|
||||
window: ?Window = null,
|
||||
};
|
||||
|
||||
/// See `ReportOptions.window`.
|
||||
const Window = struct {
|
||||
start: Date,
|
||||
end: Date,
|
||||
};
|
||||
|
||||
/// Where a CD's maturity falls relative to the diff window. End-of-day
|
||||
/// semantics like everywhere else: a CD maturing on the start date had
|
||||
/// already matured when the before side was taken.
|
||||
const CdMaturity = enum {
|
||||
/// Not a CD, no maturity, no window, or not yet matured at `end`.
|
||||
none,
|
||||
/// Matured in (start, end]: its payout belongs to this window.
|
||||
in_window,
|
||||
/// Matured on or before `start`: its payout belonged to an earlier
|
||||
/// window, so closing or deleting it now is housekeeping.
|
||||
before_window,
|
||||
};
|
||||
|
||||
fn cdMaturity(lot: Lot, window: ?Window) CdMaturity {
|
||||
if (lot.security_type != .cd) return .none;
|
||||
const w = window orelse return .none;
|
||||
if (lot.hasMaturedAsOf(w.start)) return .before_window;
|
||||
if (lot.hasMaturedAsOf(w.end)) return .in_window;
|
||||
return .none;
|
||||
}
|
||||
|
||||
fn computeReport(
|
||||
allocator: std.mem.Allocator,
|
||||
before: []const Lot,
|
||||
|
|
@ -2272,7 +2338,12 @@ fn computeReport(
|
|||
// 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) {
|
||||
//
|
||||
// Except a CD that matured before this window: its payout was
|
||||
// counted in the window containing its maturity date (see
|
||||
// below), so recording its close now is housekeeping.
|
||||
const cd_maturity = cdMaturity(lot, opts.window);
|
||||
if (@abs(split.sold) > 0.000001 and cd_maturity != .before_window) {
|
||||
try appendSale(allocator, &changes, sym, acct, lot.security_type, split.sold, after_agg.sold_value - before_agg.sold_value, !after_agg.sold_unpriced);
|
||||
}
|
||||
|
||||
|
|
@ -2346,7 +2417,32 @@ fn computeReport(
|
|||
.unit_value = unit_value,
|
||||
});
|
||||
} else if (@abs(split.sold) <= 0.000001) {
|
||||
// Same held shares and no sale: only metadata changed.
|
||||
// Same held shares and no sale.
|
||||
//
|
||||
// A CD whose maturity date falls in this window paid out,
|
||||
// even though nothing about its line changed - left in
|
||||
// place, or archived to a sibling file unchanged. That is
|
||||
// a funding source exactly like a deleted matured CD. Uses
|
||||
// the AFTER side's maturity, so a CD renewed by pushing
|
||||
// `maturity_date` out isn't a payout.
|
||||
// Held shares only: a CD redeemed early (already sold)
|
||||
// whose original maturity date comes round later paid
|
||||
// out when it was closed.
|
||||
if (cd_maturity == .in_window and @abs(after_agg.unsold()) > 0.000001) {
|
||||
const held = after_agg.unsold();
|
||||
const unit_value = outflowUnitValue(lot, prices, as_of);
|
||||
try changes.append(allocator, .{
|
||||
.kind = .cd_matured,
|
||||
.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,
|
||||
});
|
||||
}
|
||||
// Otherwise only metadata changed.
|
||||
const before_lot = before_agg.lot;
|
||||
const a_price = lot.price;
|
||||
const b_price = before_lot.price;
|
||||
|
|
@ -2452,6 +2548,12 @@ fn computeReport(
|
|||
const acct = try sdup.of(lot.account orelse "");
|
||||
const sym = try sdup.of(lot.symbol);
|
||||
|
||||
// A CD that matured before this window paid out in the window
|
||||
// containing its maturity date; deleting its line now is
|
||||
// housekeeping. (Without a window this can't be known, and the
|
||||
// deletion funds this window as before.)
|
||||
if (cdMaturity(lot, opts.window) == .before_window) continue;
|
||||
|
||||
var kind: ChangeKind = .lot_removed;
|
||||
if (lot.security_type == .cd) {
|
||||
// Matured specifically, not "ended": a CD redeemed early
|
||||
|
|
@ -3653,6 +3755,166 @@ test "matchIntraAccountPurchases: a CD payout can't absorb an unrelated deposit
|
|||
try std.testing.expectApproxEqAbs(@as(f64, 5000), attributionTotalForTest(report), 0.01);
|
||||
}
|
||||
|
||||
// ── CD maturity inside the diff window (see `ReportOptions.window`) ──
|
||||
|
||||
/// A window around a CD maturing 2026-03-01.
|
||||
const mar_window: Window = .{ .start = Date.fromYmd(2026, 2, 20), .end = Date.fromYmd(2026, 3, 6) };
|
||||
const later_window: Window = .{ .start = Date.fromYmd(2026, 6, 1), .end = Date.fromYmd(2026, 6, 8) };
|
||||
|
||||
fn windowReport(a: std.mem.Allocator, before: []const Lot, after: []const Lot, window: ?Window) !Report {
|
||||
var prices = std.StringHashMap(f64).init(a);
|
||||
const as_of = if (window) |w| w.end else Date.fromYmd(2026, 6, 8);
|
||||
return computeReport(a, before, after, &prices, as_of, .{ .window = window });
|
||||
}
|
||||
|
||||
test "window: a CD left in the file that matures in the window funds a rollover" {
|
||||
// The case the maturity date alone couldn't handle: CD1's line is
|
||||
// identical on both sides (left in place, or archived unchanged),
|
||||
// CD2 is bought with the payout, no cash visibly moves.
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const a = arena_state.allocator();
|
||||
const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||||
const cd2 = testCd("CD2", "Sample IRA", Date.fromYmd(2026, 3, 2), Date.fromYmd(2027, 3, 2));
|
||||
|
||||
const with = try windowReport(a, &.{cd1}, &.{ cd1, cd2 }, mar_window);
|
||||
try std.testing.expectEqual(@as(usize, 1), countKind(with, .cd_matured));
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(with), 0.01);
|
||||
|
||||
// Without window dates it can't be known; the new CD counts, as before.
|
||||
const without = try windowReport(a, &.{cd1}, &.{ cd1, cd2 }, null);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 10_000), attributionTotalForTest(without), 0.01);
|
||||
}
|
||||
|
||||
test "window: an unchanged CD pays out only in the window containing its maturity" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const a = arena_state.allocator();
|
||||
const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||||
// Months later the line is still there, untouched: nothing again.
|
||||
const later = try windowReport(a, &.{cd1}, &.{cd1}, later_window);
|
||||
try std.testing.expectEqual(@as(usize, 0), later.changes.len);
|
||||
// Not yet matured: nothing either.
|
||||
const early: Window = .{ .start = Date.fromYmd(2026, 1, 1), .end = Date.fromYmd(2026, 2, 28) };
|
||||
const before = try windowReport(a, &.{cd1}, &.{cd1}, early);
|
||||
try std.testing.expectEqual(@as(usize, 0), before.changes.len);
|
||||
}
|
||||
|
||||
test "window: maturing on the window's start date belongs to the earlier window" {
|
||||
// End-of-day semantics: on the start date it had already matured.
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const a = arena_state.allocator();
|
||||
const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||||
const on_start: Window = .{ .start = Date.fromYmd(2026, 3, 1), .end = Date.fromYmd(2026, 3, 8) };
|
||||
const on_end: Window = .{ .start = Date.fromYmd(2026, 2, 22), .end = Date.fromYmd(2026, 3, 1) };
|
||||
try std.testing.expectEqual(@as(usize, 0), (try windowReport(a, &.{cd1}, &.{cd1}, on_start)).changes.len);
|
||||
try std.testing.expectEqual(@as(usize, 1), countKind(try windowReport(a, &.{cd1}, &.{cd1}, on_end), .cd_matured));
|
||||
}
|
||||
|
||||
test "window: deleting a CD long after it matured is housekeeping" {
|
||||
// Its payout landed in its maturity window. This is the late-deletion
|
||||
// caveat the window-free fix had: the face value used to fund - and
|
||||
// so hide - a real contribution in the later window.
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const a = arena_state.allocator();
|
||||
const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||||
const buy: Lot = .{ .symbol = "VTI", .shares = 40, .open_date = Date.fromYmd(2026, 6, 3), .open_price = 250, .account = "Sample IRA" };
|
||||
const report = try windowReport(a, &.{cd1}, &.{buy}, later_window);
|
||||
try std.testing.expectEqual(@as(usize, 0), countKind(report, .cd_matured));
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 10_000), attributionTotalForTest(report), 0.01);
|
||||
}
|
||||
|
||||
test "window: deleting a CD in the window it matures still pays out" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const a = arena_state.allocator();
|
||||
const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||||
const cd2 = testCd("CD2", "Sample IRA", Date.fromYmd(2026, 3, 2), Date.fromYmd(2027, 3, 2));
|
||||
const report = try windowReport(a, &.{cd1}, &.{cd2}, mar_window);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01);
|
||||
}
|
||||
|
||||
test "window: closing a CD long after it matured is housekeeping" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const a = arena_state.allocator();
|
||||
const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||||
var closed = cd1;
|
||||
closed.close_date = Date.fromYmd(2026, 3, 1);
|
||||
closed.close_price = 1;
|
||||
const report = try windowReport(a, &.{cd1}, &.{closed}, later_window);
|
||||
try std.testing.expectEqual(@as(usize, 0), report.changes.len);
|
||||
}
|
||||
|
||||
test "window: closing a CD in the window it matures pays out once, not twice" {
|
||||
// The close is the sale; the in-window maturity must not add a second
|
||||
// funding event for the same money.
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const a = arena_state.allocator();
|
||||
const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||||
var closed = cd1;
|
||||
closed.close_date = Date.fromYmd(2026, 3, 1);
|
||||
closed.close_price = 1;
|
||||
const report = try windowReport(a, &.{cd1}, &.{closed}, mar_window);
|
||||
try std.testing.expectEqual(@as(usize, 1), countKind(report, .position_closed) + countKind(report, .cd_matured));
|
||||
}
|
||||
|
||||
test "window: a CD redeemed early doesn't pay out again at its original maturity" {
|
||||
// Closed in January; its March maturity date passing later is not a
|
||||
// second payout, nor a zero-value "matured" row.
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const a = arena_state.allocator();
|
||||
var redeemed = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||||
redeemed.close_date = Date.fromYmd(2026, 1, 15);
|
||||
redeemed.close_price = 1;
|
||||
const report = try windowReport(a, &.{redeemed}, &.{redeemed}, mar_window);
|
||||
try std.testing.expectEqual(@as(usize, 0), report.changes.len);
|
||||
}
|
||||
|
||||
test "window: renewing a CD by extending maturity_date isn't a payout" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const a = arena_state.allocator();
|
||||
const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||||
var renewed = cd1;
|
||||
renewed.maturity_date = Date.fromYmd(2027, 3, 1);
|
||||
const report = try windowReport(a, &.{cd1}, &.{renewed}, mar_window);
|
||||
try std.testing.expectEqual(@as(usize, 0), countKind(report, .cd_matured));
|
||||
}
|
||||
|
||||
test "window: a payout parked in cash isn't new money on a cash_is_contribution account" {
|
||||
// The G1b case, now with the CD left in the file: the cash rise is
|
||||
// offset by the payout, leaving only the $300 interest.
|
||||
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 cd1 = 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, &.{ cd1, cash0 }, &.{ cd1, cash1 }, &prices, mar_window.end, .{ .account_map = &am, .window = mar_window });
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 300), attributionTotalForTest(report), 0.01);
|
||||
}
|
||||
|
||||
test "cdMaturity: only CDs, and only with a window" {
|
||||
const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1));
|
||||
try std.testing.expectEqual(CdMaturity.in_window, cdMaturity(cd1, mar_window));
|
||||
try std.testing.expectEqual(CdMaturity.before_window, cdMaturity(cd1, later_window));
|
||||
try std.testing.expectEqual(CdMaturity.none, cdMaturity(cd1, null));
|
||||
var option = cd1;
|
||||
option.security_type = .option;
|
||||
try std.testing.expectEqual(CdMaturity.none, cdMaturity(option, mar_window));
|
||||
var no_maturity = cd1;
|
||||
no_maturity.maturity_date = null;
|
||||
try std.testing.expectEqual(CdMaturity.none, cdMaturity(no_maturity, mar_window));
|
||||
}
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────
|
||||
|
||||
fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void {
|
||||
|
|
@ -6102,6 +6364,58 @@ test "prepareReport: a lot archived into a sibling portfolio file is one sale" {
|
|||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), summarizeAttribution(ctx).total(), 0.01);
|
||||
}
|
||||
|
||||
test "prepareReport: a CD archived unchanged pays out in the commits that span its maturity" {
|
||||
// End to end: the window comes from the two commits' dates, with no
|
||||
// edit to the CD's line at all - it just moves to the archive file,
|
||||
// exactly as a matured CD usually does.
|
||||
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];
|
||||
|
||||
const cd1 = "security_type::cd,symbol::CD1,shares:num:10000,open_date::2025-03-01,open_price:num:1,maturity_date::2026-03-01,account::Sample IRA\n";
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "#!srfv1\n" ++ cd1 });
|
||||
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-02-20T12:00:00", &.{ "commit", "-q", "-m", "before" });
|
||||
|
||||
// After maturity: CD1 archived unchanged, CD2 bought with the payout.
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data =
|
||||
\\#!srfv1
|
||||
\\security_type::cd,symbol::CD2,shares:num:10000,open_date::2026-03-02,open_price:num:1,maturity_date::2027-03-02,account::Sample IRA
|
||||
\\
|
||||
});
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_closed.srf", .data = "#!srfv1\n" ++ cd1 });
|
||||
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf", "portfolio_closed.srf" });
|
||||
try test_git.run(allocator, dir, "2026-03-06T12:00:00", &.{ "commit", "-q", "-m", "rolled" });
|
||||
|
||||
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" });
|
||||
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, 3, 10), false, .never, .silent) catch return error.PrepareFailed;
|
||||
defer ctx.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), countKind(ctx.report, .cd_matured));
|
||||
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.
|
||||
|
|
|
|||
28
src/git.zig
28
src/git.zig
|
|
@ -676,6 +676,21 @@ pub fn commitTimestamp(
|
|||
return std.fmt.parseInt(i64, trimmed, 10) catch return error.GitLogFailed;
|
||||
}
|
||||
|
||||
/// The UTC calendar day `ref` was committed (committer date, `%ct`).
|
||||
///
|
||||
/// The one place a commit becomes a `Date`: callers that need "which
|
||||
/// day was this commit" should use this rather than pairing
|
||||
/// `commitTimestamp` with `Date.fromEpoch` themselves.
|
||||
pub fn commitDate(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
root: []const u8,
|
||||
ref: []const u8,
|
||||
) Error!Date {
|
||||
return Date.fromEpoch(try commitTimestamp(io, allocator, env, root, ref));
|
||||
}
|
||||
|
||||
/// Resolve a before/after commit range for diffing `repo.rel_path`.
|
||||
///
|
||||
/// Three modes selected by `since` / `until`:
|
||||
|
|
@ -1044,6 +1059,19 @@ test "commitAtOrBeforeDate returns a SHA for a past date" {
|
|||
for (sha) |c| try std.testing.expect(std.ascii.isHex(c));
|
||||
}
|
||||
|
||||
test "commitDate is the committer day of commitTimestamp" {
|
||||
const allocator = std.testing.allocator;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
|
||||
const ts = commitTimestamp(std.testing.io, allocator, &env, info.root, "HEAD") catch return;
|
||||
const d = try commitDate(std.testing.io, allocator, &env, info.root, "HEAD");
|
||||
try std.testing.expect(d.eql(Date.fromEpoch(ts)));
|
||||
}
|
||||
|
||||
test "commitAtOrBeforeDate returns null for date before repo existed" {
|
||||
const allocator = std.testing.allocator;
|
||||
var env = gitTestEnv(allocator);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue