diff --git a/TODO.md b/TODO.md index 94a4351..8fb6c88 100644 --- a/TODO.md +++ b/TODO.md @@ -548,36 +548,6 @@ gain. Possible fixes are discussed in the "Contributions diff" TODO below — option C there (per-account `cash_is_contribution`) would make manually-entered ESPP-style cash additions count correctly. -## Investigate: small dollar-value discrepancy between consecutive `compare` runs - -**Symptom:** Last week's `zfin compare 1W` reported a "now" Total -Investable Assets that didn't match this week's `zfin compare 1W` -"then" value by a small but non-zero amount (low single-digit -thousands). The two numbers should be identical — both are reading -the same week-ago history snapshot file. - -Candidates to investigate: - -- Snapshot file mutated after last week's run (re-snapshot? manual - edit?). Check `git log -- history/-portfolio.srf` and - `git diff` against any prior version if tracked. -- Live cache prices changed between runs and the snapshot path is - somehow falling through to live prices for some symbols. The - snapshot SHOULD be self-contained (shares × snapshotted price), - but verify by re-running `zfin compare ` (same date - both ends) and confirming the same number both ways. -- Last week's reported "now" was computed against a working-copy - portfolio that was later edited (reconciliation tweak post-run), - while this week's "then" reads the committed snapshot. Cross-check - against any archived report output from last week. -- `price_ratio` or `adj_close` semantics differing between code paths - (the REPORT.md §2 caveat about commit-side using current prices - for DRIP/rollup deltas is a known inconsistency in attribution — - may or may not apply here). - -If the source is the working-copy-vs-snapshot mismatch (third bullet), -that's a workflow issue, not a bug — but worth confirming. - ## Investigate: detailed 401(k) contributions data source Found a more detailed contributions screen on at least one diff --git a/src/commands/compare.zig b/src/commands/compare.zig index 40ea3fd..cb79afe 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -605,7 +605,7 @@ const LiveSide = struct { fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?ProjectionsBlock) !void { var then_buf: [10]u8 = undefined; - var now_buf: [10]u8 = undefined; + var now_buf: [24]u8 = undefined; const then_str = std.fmt.bufPrint(&then_buf, "{f}", .{cv.then_date}) catch "????-??-??"; const now_str = view.nowLabel(cv, &now_buf); @@ -811,7 +811,7 @@ test "renderCompare: basic output includes expected elements" { const out = stream.buffered(); // Header - try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → today") != null); + try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-01-25 (live)") != null); try testing.expect(std.mem.indexOf(u8, out, "(10 days)") != null); // Totals try testing.expect(std.mem.indexOf(u8, out, "Liquid:") != null); @@ -826,7 +826,7 @@ test "renderCompare: basic output includes expected elements" { try testing.expect(std.mem.indexOf(u8, out, "(3 added, 1 removed since 2024-01-15 — hidden)") != null); } -test "renderCompare: two-snapshot mode shows real date, not 'today'" { +test "renderCompare: two-snapshot mode shows real date, no (live) marker" { const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), @@ -845,7 +845,7 @@ test "renderCompare: two-snapshot mode shows real date, not 'today'" { const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-03-15") != null); - try testing.expect(std.mem.indexOf(u8, out, "today") == null); + try testing.expect(std.mem.indexOf(u8, out, "(live)") == null); try testing.expect(std.mem.indexOf(u8, out, "No symbols held throughout") != null); // No "hidden" line when both counts are zero try testing.expect(std.mem.indexOf(u8, out, "hidden") == null); diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 64ac48d..cd6988c 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -1549,7 +1549,7 @@ pub fn renderCompareLines( // ── Header ── var then_buf: [10]u8 = undefined; - var now_buf: [10]u8 = undefined; + var now_buf: [24]u8 = undefined; const then_iso = std.fmt.bufPrint(&then_buf, "{f}", .{cv.then_date}) catch "????-??-??"; const now_iso = compare_view.nowLabel(cv, &now_buf); @@ -2005,7 +2005,7 @@ test "renderCompareLines: emits header, totals, symbols, hidden-count, footer" { try testing.expect(saw_footer); } -test "renderCompareLines: live-now shows 'today' not a date" { +test "renderCompareLines: live-now shows date with (live) marker" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); @@ -2024,13 +2024,13 @@ test "renderCompareLines: live-now shows 'today' not a date" { }; const lines = try renderCompareLines(a, th, cv, " Esc or 'c' to return to timeline"); - var saw_today = false; + var saw_live_marker = false; var saw_no_symbols = false; for (lines) |l| { - if (std.mem.indexOf(u8, l.text, "→ today") != null) saw_today = true; + if (std.mem.indexOf(u8, l.text, "2024-03-15 (live)") != null) saw_live_marker = true; if (std.mem.indexOf(u8, l.text, "No symbols held throughout") != null) saw_no_symbols = true; } - try testing.expect(saw_today); + try testing.expect(saw_live_marker); try testing.expect(saw_no_symbols); } diff --git a/src/views/compare.zig b/src/views/compare.zig index 73bbb80..afdb7ad 100644 --- a/src/views/compare.zig +++ b/src/views/compare.zig @@ -828,10 +828,49 @@ pub fn buildTotalsCells( } /// Format the "now" side label for the header. Snapshot-now shows -/// the date; live-now shows the literal "today". `buf` backs the -/// date case; caller must keep it alive. -pub fn nowLabel(cv: CompareView, buf: *[10]u8) []const u8 { - if (cv.now_is_live) return "today"; +/// the date alone; live-now shows the date with a `(live)` marker +/// so readers comparing this run against a later run (which would +/// read today's value from a snapshot file) understand why the +/// numbers might drift slightly. +/// +/// **Why the marker matters.** When `compare 1W` runs with a live +/// "now", the value it shows for "now" is computed against today's +/// state of `portfolio.srf` plus today's cached prices. Next week, +/// when the same user runs `compare 1W` again, this week's value +/// becomes "then" — but is read from the snapshot file (e.g. +/// `history/-portfolio.srf`) that was captured for the +/// snapshot's `as_of` date, not the date the user actually ran +/// `compare`. The two values can disagree slightly because: +/// +/// - **Date-arg drift.** Live `compare` on day T evaluates +/// `positionsForAccount(today=T)`, `totalCash(T)`, etc. +/// `snapshot --as-of T-1` evaluates the same against T-1. Any +/// lot whose `open_date` or `close_date` falls between those +/// two dates contributes a non-zero delta even when prices are +/// identical (e.g. a Saturday-dated RSU credit that's +/// in-scope for live Saturday-`compare` but not for the prior +/// Friday's snapshot). +/// - **Working-copy edits between runs.** Edits to +/// `portfolio.srf` made after the live `compare` run but +/// before the next `snapshot` capture are reflected in the +/// snapshot but not in the prior live "now" value (or vice +/// versa). +/// - **Cache refresh on a trading day.** When markets are open, +/// cached candle prices may refresh between the live `compare` +/// and the eventual `snapshot` capture. (On weekends and +/// holidays this source vanishes; the other two still apply.) +/// +/// The `(live)` marker tells the reader "this number is ephemeral — +/// the corresponding snapshot value may differ." Without it, users +/// reasonably assumed last week's "now" should equal this week's +/// "then" verbatim and were surprised by a few-thousand-dollar drift +/// on a multi-million-dollar portfolio. +/// +/// `buf` backs both cases; caller must keep it alive. +pub fn nowLabel(cv: CompareView, buf: *[24]u8) []const u8 { + if (cv.now_is_live) { + return std.fmt.bufPrint(buf, "{f} (live)", .{cv.now_date}) catch "today (live)"; + } return std.fmt.bufPrint(buf, "{f}", .{cv.now_date}) catch "????-??-??"; } @@ -880,7 +919,7 @@ test "buildTotalsCells: wires through the right formatters" { try testing.expectEqual(StyleIntent.positive, cells.style); } -test "nowLabel: live shows 'today', snapshot shows date" { +test "nowLabel: live shows date with (live) marker, snapshot shows date alone" { const cv_live = CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), @@ -892,8 +931,8 @@ test "nowLabel: live shows 'today', snapshot shows date" { .added_count = 0, .removed_count = 0, }; - var buf: [10]u8 = undefined; - try testing.expectEqualStrings("today", nowLabel(cv_live, &buf)); + var buf: [24]u8 = undefined; + try testing.expectEqualStrings("2024-03-15 (live)", nowLabel(cv_live, &buf)); const cv_snap = CompareView{ .then_date = Date.fromYmd(2024, 1, 15),