document difference between live and snapshot when on weekends

This commit is contained in:
Emil Lerch 2026-05-16 15:36:22 -07:00
parent 3b70275845
commit 1b7b3992ba
4 changed files with 55 additions and 46 deletions

30
TODO.md
View file

@ -548,36 +548,6 @@ gain. Possible fixes are discussed in the "Contributions diff" TODO
below — option C there (per-account `cash_is_contribution`) would below — option C there (per-account `cash_is_contribution`) would
make manually-entered ESPP-style cash additions count correctly. 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/<date>-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 <date> <date>` (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 ## Investigate: detailed 401(k) contributions data source
Found a more detailed contributions screen on at least one Found a more detailed contributions screen on at least one

View file

@ -605,7 +605,7 @@ const LiveSide = struct {
fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?ProjectionsBlock) !void { fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?ProjectionsBlock) !void {
var then_buf: [10]u8 = undefined; 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 then_str = std.fmt.bufPrint(&then_buf, "{f}", .{cv.then_date}) catch "????-??-??";
const now_str = view.nowLabel(cv, &now_buf); const now_str = view.nowLabel(cv, &now_buf);
@ -811,7 +811,7 @@ test "renderCompare: basic output includes expected elements" {
const out = stream.buffered(); const out = stream.buffered();
// Header // 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); try testing.expect(std.mem.indexOf(u8, out, "(10 days)") != null);
// Totals // Totals
try testing.expect(std.mem.indexOf(u8, out, "Liquid:") != null); 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); 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{ const cv = view.CompareView{
.then_date = Date.fromYmd(2024, 1, 15), .then_date = Date.fromYmd(2024, 1, 15),
.now_date = Date.fromYmd(2024, 3, 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(); 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, "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); try testing.expect(std.mem.indexOf(u8, out, "No symbols held throughout") != null);
// No "hidden" line when both counts are zero // No "hidden" line when both counts are zero
try testing.expect(std.mem.indexOf(u8, out, "hidden") == null); try testing.expect(std.mem.indexOf(u8, out, "hidden") == null);

View file

@ -1549,7 +1549,7 @@ pub fn renderCompareLines(
// Header // Header
var then_buf: [10]u8 = undefined; 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 then_iso = std.fmt.bufPrint(&then_buf, "{f}", .{cv.then_date}) catch "????-??-??";
const now_iso = compare_view.nowLabel(cv, &now_buf); 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); 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); var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit(); defer arena.deinit();
const a = arena.allocator(); 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"); 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; var saw_no_symbols = false;
for (lines) |l| { 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; 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); try testing.expect(saw_no_symbols);
} }

View file

@ -828,10 +828,49 @@ pub fn buildTotalsCells(
} }
/// Format the "now" side label for the header. Snapshot-now shows /// Format the "now" side label for the header. Snapshot-now shows
/// the date; live-now shows the literal "today". `buf` backs the /// the date alone; live-now shows the date with a `(live)` marker
/// date case; caller must keep it alive. /// so readers comparing this run against a later run (which would
pub fn nowLabel(cv: CompareView, buf: *[10]u8) []const u8 { /// read today's value from a snapshot file) understand why the
if (cv.now_is_live) return "today"; /// 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/<DATE>-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 "????-??-??"; 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); 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{ const cv_live = CompareView{
.then_date = Date.fromYmd(2024, 1, 15), .then_date = Date.fromYmd(2024, 1, 15),
.now_date = Date.fromYmd(2024, 3, 15), .now_date = Date.fromYmd(2024, 3, 15),
@ -892,8 +931,8 @@ test "nowLabel: live shows 'today', snapshot shows date" {
.added_count = 0, .added_count = 0,
.removed_count = 0, .removed_count = 0,
}; };
var buf: [10]u8 = undefined; var buf: [24]u8 = undefined;
try testing.expectEqualStrings("today", nowLabel(cv_live, &buf)); try testing.expectEqualStrings("2024-03-15 (live)", nowLabel(cv_live, &buf));
const cv_snap = CompareView{ const cv_snap = CompareView{
.then_date = Date.fromYmd(2024, 1, 15), .then_date = Date.fromYmd(2024, 1, 15),