diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 049c7c0..ce98666 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: - id: test name: Run zig build test entry: zig - args: ["build", "coverage", "-Dcoverage-threshold=56"] + args: ["build", "coverage", "-Dcoverage-threshold=60"] language: system types: [file] pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 84b63e1..8776ea8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ zig build # build the zfin binary (output: zig-out/bin/zfin) zig build test # run all tests (single binary, discovers all tests via refAllDeclsRecursive) zig build run -- # build and run CLI zig build docs # generate library documentation -zig build coverage -Dcoverage-threshold=56 # run tests with kcov coverage (Linux only) +zig build coverage -Dcoverage-threshold=60 # run tests with kcov coverage (Linux only) ``` **Tooling** (managed via `.mise.toml`): @@ -123,6 +123,27 @@ Tests use `std.testing.allocator` (which detects leaks) and are structured as un 2. Add the tab variant to `tui.Tab` enum 3. Wire rendering in `tui.zig`'s draw and event handling +### Command `run()` signatures — allocator as code smell + +A CLI command's `run()` function that takes `*DataService` and `*std.Io.Writer` +usually doesn't also need an `std.mem.Allocator` parameter. `FetchResult(T)` +carries its own allocator and self-deinits (see `src/service.zig`), so callers +never need to wire up matching allocators for payload cleanup. The writer +owns its own buffer. + +If a new `run()` signature still wants an allocator, ask whether the work +it's funding is: + +- **Legitimate**: file I/O for a secondary config (portfolio load, + metadata), non-trivial intermediate computation, or an arena wrapping + view-layer allocations. Keep it. +- **Suspicious**: freeing `FetchResult.data` manually instead of calling + `result.deinit()`, or duplicating strings that could be borrowed. + Drop the allocator and fix the leak-shaped helper. + +Not a hard rule — just a signal worth questioning when reviewing a new +command. + ## Gotchas - **Provider field naming is comptime-derived.** `DataService.getProvider(T)` finds the `?T` field by iterating struct fields at comptime, and the config key is derived by lowercasing the type name and appending `_key`. If you rename a provider struct, you must also rename the config field or the comptime logic breaks. diff --git a/TODO.md b/TODO.md index 5c947b0..f4cf6a5 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,23 @@ value and re-run `computePercentileBands` with that starting point, then plot actual values from later snapshots as a line overlaid on the bands. + `zfin projections --as-of ` already reruns the simulation + against a past snapshot (the prerequisite for this overlay). What's + missing is the overlay itself — loading multiple downstream snapshots + and plotting their net-worth trajectory on the same chart. + + **Deferred to ~2027.** Needs a practical volume of real snapshots + (currently building up; meaningful backtest requires 12+ months). + Backfilling from git history is not viable — the lot-level state on + portfolio.srf at a past commit is insufficient to reconstruct the + full transaction+contribution picture. Revisit once there are 12+ + months of continuous snapshot data. + + Also consider: `metadata.srf` and `projections.srf` classifications / + assumptions drift over time. For back-dated runs we currently use + the current versions of both; historical git-tracked versions could + be checked out and loaded instead. Edge case for now. + ## Analysis account/asset-class total mismatch The "By Account" and "By Tax Type" sections in the analysis command sum to slightly diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index fe7bf1c..007ba46 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -18,9 +18,9 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co for (positions) |pos| { if (pos.shares <= 0) continue; if (svc.getCachedCandles(pos.symbol)) |cs| { - defer allocator.free(cs); - if (cs.len > 0) { - try prices.put(pos.symbol, cs[cs.len - 1].close); + defer cs.deinit(); + if (cs.data.len > 0) { + try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); } } } diff --git a/src/commands/common.zig b/src/commands/common.zig index 1e083bc..88e562f 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -46,6 +46,64 @@ pub fn setStyleIntent(out: *std.Io.Writer, c: bool, intent: fmt.StyleIntent) !vo } } +// ── Styled print helpers ───────────────────────────────────── +// +// Collapse the common `setX; print(...); reset` triple into a single +// call. Every renderer used to spell out all three steps; these +// helpers keep the "set → write → reset" boundary intact while +// cutting line count roughly in half at the call site. + +/// Set a foreground color, print a formatted string, reset. +pub fn printFg( + out: *std.Io.Writer, + c: bool, + rgb: [3]u8, + comptime fmt_str: []const u8, + args: anytype, +) !void { + try setFg(out, c, rgb); + try out.print(fmt_str, args); + try reset(out, c); +} + +/// Set a bold attribute, print a formatted string, reset. +pub fn printBold( + out: *std.Io.Writer, + c: bool, + comptime fmt_str: []const u8, + args: anytype, +) !void { + try setBold(out, c); + try out.print(fmt_str, args); + try reset(out, c); +} + +/// Set a semantic-intent color, print a formatted string, reset. +pub fn printIntent( + out: *std.Io.Writer, + c: bool, + intent: fmt.StyleIntent, + comptime fmt_str: []const u8, + args: anytype, +) !void { + try setStyleIntent(out, c, intent); + try out.print(fmt_str, args); + try reset(out, c); +} + +/// Set a sign-aware gain/loss color, print a formatted string, reset. +pub fn printGainLoss( + out: *std.Io.Writer, + c: bool, + value: f64, + comptime fmt_str: []const u8, + args: anytype, +) !void { + try setGainLoss(out, c, value); + try out.print(fmt_str, args); + try reset(out, c); +} + // ── Stderr helpers ─────────────────────────────────────────── pub fn stderrPrint(msg: []const u8) !void { @@ -357,7 +415,11 @@ pub fn buildPortfolioData( } for (syms) |sym| { if (svc.getCachedCandles(sym)) |cs| { - candle_map.put(sym, cs) catch {}; + // cs.data is owned by svc.allocator(), which matches the + // caller's `allocator` in practice (they're wired to the + // same root). Store the raw slice; PortfolioData.deinit + // below frees via the caller's allocator. + candle_map.put(sym, cs.data) catch {}; } } @@ -375,6 +437,90 @@ pub fn buildPortfolioData( }; } +// ── As-of date parsing (shared by CLI --as-of and TUI date popup) ── + +pub const AsOfParseError = error{ + InvalidFormat, + EmptyUnit, + UnknownUnit, + ZeroQuantity, +}; + +/// Parse a user-supplied as-of string into an optional `Date`. +/// +/// Return value: `null` means live (today's portfolio); a non-null +/// `Date` is the resolved absolute date the caller should look up in +/// the snapshot directory. Relative forms (`1M`, `3Y`, ...) are +/// converted here — callers receive the resolved date, not the +/// shortcut string. +/// +/// Accepted forms (case-insensitive for keywords and unit letters): +/// - "" → null (empty = live) +/// - "live" / "now" → null +/// - "YYYY-MM-DD" → explicit date +/// - "N[WMQY]" → today − N units; calendar arithmetic +/// +/// Units: +/// - W = weeks (subtract N * 7 days) +/// - M = months (calendar; Mar 31 - 1M → Feb 28/29) +/// - Q = quarters (3 months) +/// - Y = years (calendar; Feb 29 - 1Y → Feb 28) +/// +/// `today` is injected rather than read from the clock so tests are +/// deterministic. In production call sites this is `fmt.todayDate()`. +/// +/// Fractional forms like `1.5Y` are not accepted — keep the parser +/// small and unambiguous. +pub fn parseAsOfDate(input: []const u8, today: zfin.Date) AsOfParseError!?zfin.Date { + const s = std.mem.trim(u8, input, " \t\r\n"); + + if (s.len == 0) return null; + + // Keyword forms. + if (std.ascii.eqlIgnoreCase(s, "live") or std.ascii.eqlIgnoreCase(s, "now")) { + return null; + } + + // Explicit YYYY-MM-DD. + if (s.len == 10 and s[4] == '-' and s[7] == '-') { + return zfin.Date.parse(s) catch error.InvalidFormat; + } + + // Relative: N[WMQY]. + // Digits prefix then a single unit letter. + var i: usize = 0; + while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) {} + if (i == 0) return error.InvalidFormat; + if (i >= s.len) return error.EmptyUnit; + if (i + 1 != s.len) return error.InvalidFormat; + + // u16 is the widest quantity that all downstream ops (subtractYears, + // subtractMonths, addDays) accept without further narrowing. + const n = std.fmt.parseInt(u16, s[0..i], 10) catch return error.InvalidFormat; + if (n == 0) return error.ZeroQuantity; + + const unit = std.ascii.toLower(s[i]); + return switch (unit) { + 'w' => today.addDays(-@as(i32, n) * 7), + 'm' => today.subtractMonths(n), + 'q' => today.subtractMonths(n * 3), + 'y' => today.subtractYears(n), + else => error.UnknownUnit, + }; +} + +/// Human-readable explanation of why a given string failed to parse. +/// Caller-owned buffer; returns a slice. No trailing newline — the +/// caller is responsible for formatting the surrounding message. +pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 { + return switch (err) { + error.InvalidFormat => std.fmt.bufPrint(buf, "Invalid as-of value: {s}. Expected YYYY-MM-DD, N[WMQY] (e.g. 1M, 3Q, 2Y), or 'live'.", .{input}) catch input, + error.EmptyUnit => std.fmt.bufPrint(buf, "As-of value {s} is missing a unit. Expected one of W, M, Q, Y.", .{input}) catch input, + error.UnknownUnit => std.fmt.bufPrint(buf, "As-of value {s} has an unknown unit. Expected one of W (weeks), M (months), Q (quarters), Y (years).", .{input}) catch input, + error.ZeroQuantity => std.fmt.bufPrint(buf, "As-of quantity must be at least 1 (got {s}).", .{input}) catch input, + }; +} + // ── Watchlist loading ──────────────────────────────────────── /// Load a watchlist SRF file containing symbol records. @@ -498,3 +644,142 @@ test "setGainLoss treats zero as positive" { // Should use positive (green) color for zero try std.testing.expect(std.mem.indexOf(u8, out, "127") != null); } + +// ── parseAsOfDate tests ───────────────────────────────────── + +test "parseAsOfDate: empty string is live" { + const today = zfin.Date.fromYmd(2026, 4, 2); + const r = try parseAsOfDate("", today); + try std.testing.expect(r == null); +} + +test "parseAsOfDate: whitespace-only is live" { + const today = zfin.Date.fromYmd(2026, 4, 2); + const r = try parseAsOfDate(" \t\n", today); + try std.testing.expect(r == null); +} + +test "parseAsOfDate: literal 'live' and 'now' (case-insensitive)" { + const today = zfin.Date.fromYmd(2026, 4, 2); + try std.testing.expect((try parseAsOfDate("live", today)) == null); + try std.testing.expect((try parseAsOfDate("LIVE", today)) == null); + try std.testing.expect((try parseAsOfDate("now", today)) == null); + try std.testing.expect((try parseAsOfDate("Now", today)) == null); +} + +test "parseAsOfDate: explicit YYYY-MM-DD" { + const today = zfin.Date.fromYmd(2026, 4, 2); + const r = try parseAsOfDate("2026-03-13", today); + try std.testing.expect(r != null); + try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 3, 13))); +} + +test "parseAsOfDate: weeks subtracts 7*N days" { + const today = zfin.Date.fromYmd(2026, 4, 2); + const r = try parseAsOfDate("2W", today); + // 2026-04-02 - 14 days = 2026-03-19 + try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 3, 19))); +} + +test "parseAsOfDate: months uses calendar arithmetic" { + const today = zfin.Date.fromYmd(2026, 4, 2); + const r = try parseAsOfDate("1M", today); + try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 3, 2))); +} + +test "parseAsOfDate: month-end clamping" { + // 2026-03-31 - 1 month = 2026-02-28 (non-leap) + const today = zfin.Date.fromYmd(2026, 3, 31); + const r = try parseAsOfDate("1M", today); + try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 2, 28))); +} + +test "parseAsOfDate: quarter = 3 months" { + const today = zfin.Date.fromYmd(2026, 4, 2); + const r = try parseAsOfDate("1Q", today); + try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 1, 2))); + const r2 = try parseAsOfDate("2Q", today); + try std.testing.expect(r2.?.eql(zfin.Date.fromYmd(2025, 10, 2))); +} + +test "parseAsOfDate: years uses calendar arithmetic" { + const today = zfin.Date.fromYmd(2026, 4, 2); + const r = try parseAsOfDate("3Y", today); + try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2023, 4, 2))); +} + +test "parseAsOfDate: leap year clamping via years" { + const today = zfin.Date.fromYmd(2024, 2, 29); + const r = try parseAsOfDate("1Y", today); + try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2023, 2, 28))); +} + +test "parseAsOfDate: unit letter is case-insensitive" { + const today = zfin.Date.fromYmd(2026, 4, 2); + const r_lower = try parseAsOfDate("1m", today); + const r_upper = try parseAsOfDate("1M", today); + try std.testing.expect(r_lower.?.eql(r_upper.?)); +} + +test "parseAsOfDate: invalid date format" { + const today = zfin.Date.fromYmd(2026, 4, 2); + try std.testing.expectError(error.InvalidFormat, parseAsOfDate("2026/03/13", today)); + // Digits-only with no unit falls through to the relative-form parser. + // It's technically 8 digits with no unit letter, so EmptyUnit is correct. + try std.testing.expectError(error.EmptyUnit, parseAsOfDate("20260313", today)); +} + +test "parseAsOfDate: missing unit" { + const today = zfin.Date.fromYmd(2026, 4, 2); + try std.testing.expectError(error.EmptyUnit, parseAsOfDate("3", today)); +} + +test "parseAsOfDate: unknown unit" { + const today = zfin.Date.fromYmd(2026, 4, 2); + try std.testing.expectError(error.UnknownUnit, parseAsOfDate("3X", today)); + try std.testing.expectError(error.UnknownUnit, parseAsOfDate("3D", today)); +} + +test "parseAsOfDate: zero quantity rejected" { + const today = zfin.Date.fromYmd(2026, 4, 2); + try std.testing.expectError(error.ZeroQuantity, parseAsOfDate("0M", today)); +} + +test "parseAsOfDate: quantity that overflows u16 is InvalidFormat" { + // 70000 doesn't fit in u16; previously rejected via an arbitrary cap. + // Now the underlying parseInt call rejects it as a format error. + const today = zfin.Date.fromYmd(2026, 4, 2); + try std.testing.expectError(error.InvalidFormat, parseAsOfDate("70000Y", today)); +} + +test "parseAsOfDate: large-but-valid quantity accepted" { + // 100Y is silly but parses fine — no arbitrary cap. + const today = zfin.Date.fromYmd(2026, 4, 2); + const r = try parseAsOfDate("100Y", today); + try std.testing.expect(r.?.eql(zfin.Date.fromYmd(1926, 4, 2))); +} + +test "parseAsOfDate: garbage after digits" { + const today = zfin.Date.fromYmd(2026, 4, 2); + try std.testing.expectError(error.InvalidFormat, parseAsOfDate("3MM", today)); + try std.testing.expectError(error.InvalidFormat, parseAsOfDate("3 M", today)); +} + +test "parseAsOfDate: garbage before digits" { + const today = zfin.Date.fromYmd(2026, 4, 2); + try std.testing.expectError(error.InvalidFormat, parseAsOfDate("x3M", today)); +} + +test "fmtAsOfParseError: mentions the input and hint" { + var buf: [256]u8 = undefined; + const msg = fmtAsOfParseError(&buf, "2026/03/13", error.InvalidFormat); + try std.testing.expect(std.mem.indexOf(u8, msg, "2026/03/13") != null); + try std.testing.expect(std.mem.indexOf(u8, msg, "YYYY-MM-DD") != null); +} + +test "fmtAsOfParseError: no trailing newline" { + var buf: [256]u8 = undefined; + const msg = fmtAsOfParseError(&buf, "bad", error.InvalidFormat); + try std.testing.expect(msg.len > 0); + try std.testing.expect(msg[msg.len - 1] != '\n'); +} diff --git a/src/commands/compare.zig b/src/commands/compare.zig index 8db48ec..4d7e92d 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -50,9 +50,11 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; const Date = zfin.Date; -const history_io = @import("../history.zig"); +const history = @import("../history.zig"); const compare_core = @import("../compare.zig"); const view = @import("../views/compare.zig"); +const view_hist = @import("../views/history.zig"); +const contributions_cmd = @import("contributions.zig"); pub const Error = error{ UnexpectedArg, @@ -137,7 +139,7 @@ pub fn run( } // ── Resolve history dir ────────────────────────────────── - const hist_dir = try history_io.deriveHistoryDir(allocator, portfolio_path); + const hist_dir = try history.deriveHistoryDir(allocator, portfolio_path); defer allocator.free(hist_dir); // ── Load both sides ────────────────────────────────────── @@ -159,18 +161,20 @@ pub fn run( var now_live = try LiveSide.load(allocator, svc, portfolio_path, color); defer now_live.deinit(allocator); - try renderFromParts( - out, - color, - then_date, - now_date, - true, - then_side.liquid, - now_live.liquid, - &then_side.map, - &now_live.map, - allocator, - ); + // Attribution spans then_date → HEAD (or working copy if dirty). + // `computeAttribution` with until=null uses exactly that semantics. + const attribution = contributions_cmd.computeAttribution(allocator, svc, portfolio_path, then_date, null, color); + + try renderFromParts(out, color, allocator, .{ + .then_date = then_date, + .now_date = now_date, + .now_is_live = true, + .then_liquid = then_side.liquid, + .now_liquid = now_live.liquid, + .then_map = &then_side.map, + .now_map = &now_live.map, + .attribution = attribution, + }); } else { var now_side = compare_core.loadSnapshotSide(allocator, hist_dir, now_date) catch |err| switch (err) { error.FileNotFound => { @@ -181,27 +185,30 @@ pub fn run( }; defer now_side.deinit(allocator); - try renderFromParts( - out, - color, - then_date, - now_date, - false, - then_side.liquid, - now_side.liquid, - &then_side.map, - &now_side.map, - allocator, - ); + // Attribution spans the explicit then_date → now_date window. + const attribution = contributions_cmd.computeAttribution(allocator, svc, portfolio_path, then_date, now_date, color); + + try renderFromParts(out, color, allocator, .{ + .then_date = then_date, + .now_date = now_date, + .now_is_live = false, + .then_liquid = then_side.liquid, + .now_liquid = now_side.liquid, + .then_map = &then_side.map, + .now_map = &now_side.map, + .attribution = attribution, + }); } } -/// Build the view from two holdings maps + totals, then render. -/// Factored out so both the live and snapshot "now" paths share a -/// single call site. -fn renderFromParts( - out: *std.Io.Writer, - color: bool, +/// Inputs needed to build + render a `CompareView`. Bundled into a +/// struct so `renderFromParts` stays one line of call-site noise +/// instead of an 11-positional-arg parade. +/// +/// `then_map` / `now_map` are borrowed pointers; the caller keeps the +/// underlying maps alive through the render call. `attribution` is +/// optional and folded into the view only when set. +const RenderArgs = struct { then_date: Date, now_date: Date, now_is_live: bool, @@ -209,20 +216,45 @@ fn renderFromParts( now_liquid: f64, then_map: *const view.HoldingMap, now_map: *const view.HoldingMap, + attribution: ?contributions_cmd.AttributionSummary, +}; + +/// Build the view from two holdings maps + totals, then render. +/// Factored out so both the live and snapshot "now" paths share a +/// single call site. +/// +/// `args.attribution` is optional — when the contributions pipeline +/// resolves cleanly against the portfolio's git history, the +/// contributions-vs-gains split is surfaced in the rendered output. +/// Null when git is unavailable or the window doesn't map to commits. +fn renderFromParts( + out: *std.Io.Writer, + color: bool, allocator: std.mem.Allocator, + args: RenderArgs, ) !void { var cv = try view.buildCompareView( allocator, - then_date, - now_date, - now_is_live, - then_liquid, - now_liquid, - then_map, - now_map, + args.then_date, + args.now_date, + args.now_is_live, + args.then_liquid, + args.now_liquid, + args.then_map, + args.now_map, ); defer cv.deinit(allocator); + // Wire the attribution into the view so the renderer can surface + // it. `total()` is the caller's numeric — gains are derived from + // the liquid delta. + if (args.attribution) |a| { + cv.attribution = .{ + .contributions = a.total(), + .gains = cv.liquid.delta - a.total(), + }; + } + try renderCompare(out, color, cv); } @@ -306,13 +338,13 @@ const LiveSide = struct { /// Print a "no snapshot for " message plus the nearest earlier /// and later available dates to stderr. Wraps the pure -/// `history_io.findNearestSnapshot` with CLI-specific output. +/// `history.findNearestSnapshot` with CLI-specific output. fn suggestNearest( allocator: std.mem.Allocator, hist_dir: []const u8, target: Date, ) !void { - const nearest = history_io.findNearestSnapshot(hist_dir, target) catch |err| { + const nearest = history.findNearestSnapshot(hist_dir, target) catch |err| { try cli.stderrPrint("Error scanning history directory: "); try cli.stderrPrint(@errorName(err)); try cli.stderrPrint("\n"); @@ -380,17 +412,21 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void { // Totals line — two-color: muted "then → now", intent-colored delta/pct. try renderTotalsLine(out, color, cv.liquid); + + // Optional attribution line: breaks the liquid delta into + // contributions vs. market gains/losses. Only present when the + // `compare` CLI had a git repo to work with. + if (cv.attribution) |a| { + try renderAttributionLine(out, color, cv.liquid.delta, a); + } + try out.print("\n", .{}); // Per-symbol table if (cv.held_count == 0) { - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print("No symbols held throughout this period.\n", .{}); - try cli.reset(out, color); + try cli.printFg(out, color, cli.CLR_MUTED, "No symbols held throughout this period.\n", .{}); } else { - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print("Per-symbol price change ({d} held throughout)\n", .{cv.held_count}); - try cli.reset(out, color); + try cli.printFg(out, color, cli.CLR_MUTED, "Per-symbol price change ({d} held throughout)\n", .{cv.held_count}); for (cv.symbols) |s| { try renderSymbolRow(out, color, s); @@ -400,13 +436,11 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void { // Hidden count if (cv.added_count > 0 or cv.removed_count > 0) { try out.print("\n", .{}); - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print("({d} added, {d} removed since {s} — hidden)\n", .{ + try cli.printFg(out, color, cli.CLR_MUTED, "({d} added, {d} removed since {s} — hidden)\n", .{ cv.added_count, cv.removed_count, then_str, }); - try cli.reset(out, color); } } @@ -419,14 +453,39 @@ fn renderTotalsLine(out: *std.Io.Writer, color: bool, t: view.TotalsRow) !void { const c = view.buildTotalsCells(t, &then_buf, &now_buf, &delta_buf, &pct_buf); try out.print("Liquid: ", .{}); - // "then → now" in muted color - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print("{s}{s}{s}", .{ c.then, view.arrow, c.now }); - try cli.reset(out, color); - // Delta + pct in intent color - try cli.setStyleIntent(out, color, c.style); - try out.print(" {s} {s}\n", .{ c.delta, c.pct }); - try cli.reset(out, color); + try cli.printFg(out, color, cli.CLR_MUTED, "{s}{s}{s}", .{ c.then, view.arrow, c.now }); + try cli.printIntent(out, color, c.style, " {s} {s}\n", .{ c.delta, c.pct }); +} + +/// Render the contributions-vs-gains attribution line directly beneath +/// the Liquid totals. Matches the email format: +/// +/// Attribution: +$30,148.02 delta = +$22,636.00 contributions + +$7,512.02 gains +/// +/// All three amounts are signed: negative contributions (net +/// withdrawal) and negative gains (market loss) both print with a +/// leading `-`. Indent of the label column aligns with "Liquid:". +fn renderAttributionLine(out: *std.Io.Writer, color: bool, delta: f64, attribution: view.Attribution) !void { + var delta_buf: [32]u8 = undefined; + var contrib_buf: [32]u8 = undefined; + var gains_buf: [32]u8 = undefined; + const delta_str = view_hist.fmtSignedMoneyBuf(&delta_buf, delta); + const contrib_str = view_hist.fmtSignedMoneyBuf(&contrib_buf, attribution.contributions); + const gains_str = view_hist.fmtSignedMoneyBuf(&gains_buf, attribution.gains); + + // Sign-aware joiner between `contributions` and `gains`: + // gains >= 0 → " + " (explicit addition). + // gains < 0 → " " (the leading "-" on gains_str carries the sign; + // avoids visual clutter of "+$X + -$Y"). + const joiner: []const u8 = if (attribution.gains >= 0) " + " else " "; + + try cli.printFg(out, color, cli.CLR_MUTED, "Attribution: ", .{}); + try cli.printGainLoss(out, color, delta, "{s}", .{delta_str}); + try cli.printFg(out, color, cli.CLR_MUTED, " delta = ", .{}); + try cli.printGainLoss(out, color, attribution.contributions, "{s}", .{contrib_str}); + try cli.printFg(out, color, cli.CLR_MUTED, " contributions{s}", .{joiner}); + try cli.printGainLoss(out, color, attribution.gains, "{s}", .{gains_str}); + try cli.printFg(out, color, cli.CLR_MUTED, " gains\n", .{}); } fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void { @@ -440,13 +499,9 @@ fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void // Leading indent + symbol in default color. try out.print(" " ++ view.symbol_fmt ++ " ", .{c.symbol}); // "then → now" in muted color. - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(view.price_right_fmt ++ "{s}" ++ view.price_left_fmt, .{ c.price_then, view.arrow, c.price_now }); - try cli.reset(out, color); + try cli.printFg(out, color, cli.CLR_MUTED, view.price_right_fmt ++ "{s}" ++ view.price_left_fmt, .{ c.price_then, view.arrow, c.price_now }); // Delta/pct in intent color. - try cli.setStyleIntent(out, color, c.style); - try out.print(" " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar }); - try cli.reset(out, color); + try cli.printIntent(out, color, c.style, " " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar }); } // ── Tests ──────────────────────────────────────────────────── @@ -617,6 +672,89 @@ test "renderCompare: negative totals delta" { try testing.expect(std.mem.indexOf(u8, out, "-10.00%") != null); } +test "renderCompare: attribution line when attribution is set" { + const cv = view.CompareView{ + .then_date = Date.fromYmd(2026, 3, 13), + .now_date = Date.fromYmd(2026, 4, 2), + .days_between = 20, + .now_is_live = true, + .liquid = view.buildTotalsRow(7_698_825.62, 7_728_973.64), + .symbols = &.{}, + .held_count = 0, + .added_count = 0, + .removed_count = 0, + // Numbers match the real-world email example: + // +$30,148 delta = +$22,636 contributions + +$7,512 gains + .attribution = .{ + .contributions = 22_636.00, + .gains = 7_512.02, + }, + }; + + var buf: [4096]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + try renderCompare(&stream, false, cv); + const out = stream.buffered(); + + try testing.expect(std.mem.indexOf(u8, out, "Attribution:") != null); + try testing.expect(std.mem.indexOf(u8, out, "+$30,148.02") != null); + try testing.expect(std.mem.indexOf(u8, out, "+$22,636.00") != null); + try testing.expect(std.mem.indexOf(u8, out, "+$7,512.02") != null); + try testing.expect(std.mem.indexOf(u8, out, "contributions") != null); + try testing.expect(std.mem.indexOf(u8, out, "gains") != null); +} + +test "renderCompare: no attribution line when attribution is null" { + const cv = view.CompareView{ + .then_date = Date.fromYmd(2024, 1, 15), + .now_date = Date.fromYmd(2024, 3, 15), + .days_between = 60, + .now_is_live = false, + .liquid = view.buildTotalsRow(100, 110), + .symbols = &.{}, + .held_count = 0, + .added_count = 0, + .removed_count = 0, + // attribution intentionally omitted (defaults to null) + }; + + var buf: [2048]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + try renderCompare(&stream, false, cv); + const out = stream.buffered(); + + try testing.expect(std.mem.indexOf(u8, out, "Attribution:") == null); +} + +test "renderCompare: attribution handles negative gains" { + // Window where contributions happened but market fell. + const cv = view.CompareView{ + .then_date = Date.fromYmd(2026, 3, 13), + .now_date = Date.fromYmd(2026, 4, 2), + .days_between = 20, + .now_is_live = true, + // Liquid went UP (net), but only because contributions + // overcompensated for market losses. + .liquid = view.buildTotalsRow(1_000_000, 1_005_000), + .symbols = &.{}, + .held_count = 0, + .added_count = 0, + .removed_count = 0, + .attribution = .{ + .contributions = 15_000, + .gains = -10_000, // delta − contributions = 5000 − 15000 = −10k + }, + }; + + var buf: [4096]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + try renderCompare(&stream, false, cv); + const out = stream.buffered(); + + try testing.expect(std.mem.indexOf(u8, out, "+$15,000.00") != null); + try testing.expect(std.mem.indexOf(u8, out, "-$10,000.00") != null); +} + // ── run() entry-point validation tests ───────────────────────── fn makeTestSvc() zfin.DataService { diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 31a05cd..3777f31 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -1,9 +1,20 @@ //! `zfin contributions` — show money added to the portfolio since the //! last recorded state in git. //! -//! Compares two snapshots of portfolio.srf: -//! - dirty working tree: HEAD vs working copy (default case) -//! - clean working tree: HEAD~1 vs HEAD (review last commit) +//! Four modes: +//! - No flags (default): +//! - dirty working tree: HEAD vs working copy +//! - clean working tree: HEAD~1 vs HEAD (review last commit) +//! - `--since `: commit-at-or-before(DATE) vs HEAD (or working copy if dirty) +//! - `--since --until `: commit-at-or-before(D1) vs commit-at-or-before(D2) +//! - `--until ` alone: rejected; window is ambiguous +//! +//! The `--since` / `--until` flags use `commitAtOrBeforeDate` in +//! `src/git.zig`, which runs `git log --until= -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. //! //! Classifies each lot-level change as: //! - New contribution (new lot, or cash increase for a fresh cash line) @@ -29,10 +40,20 @@ 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 fn run( allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, + since: ?Date, + until: ?Date, color: bool, out: *std.Io.Writer, ) !void { @@ -44,101 +65,352 @@ pub fn run( defer arena_state.deinit(); const arena = arena_state.allocator(); - // 1. Figure out the git repo and the portfolio's path inside it. - const repo = git.findRepo(arena, portfolio_path) catch |err| { - switch (err) { - error.NotInGitRepo => try cli.stderrPrint("Error: contributions requires portfolio.srf to be in a git repo.\n"), - error.GitUnavailable => try cli.stderrPrint("Error: could not run 'git'. Is git installed and on PATH?\n"), - else => try cli.stderrPrint("Error locating git repo.\n"), - } + // Enforce the "--until without --since is ambiguous" rule at the + // entry point so `resolveEndpoints`/`git.resolveCommitRange` can + // assume the invariant. + if (since == null and until != null) { + try cli.stderrPrint("Error: --until requires --since. Use `contributions --since ` or both.\n"); return; + } + + var ctx = prepareReport(allocator, arena, svc, portfolio_path, since, until, color, .verbose) catch return; + defer ctx.deinit(); + + try printReport(out, &ctx.report, ctx.endpoints.label, color); + try out.flush(); +} + +/// Shared pipeline context: everything `run` and `computeAttribution` +/// both need from the git-backed diff. +/// +/// Owned fields split across two allocators: +/// - `before_pf` / `after_pf` use the base allocator (their own +/// `deinit` frees internals). +/// - `endpoints`, `report`, and the snapshot blobs 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: zfin.Portfolio, + after_pf: zfin.Portfolio, + report: Report, + + fn deinit(self: *ReportContext) void { + self.before_pf.deinit(); + self.after_pf.deinit(); + } +}; + +const PrepareError = error{PrepareFailed}; + +/// Run the common pipeline — resolve endpoints, read both blobs, +/// parse both portfolios, 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. +/// +/// 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( + allocator: std.mem.Allocator, + arena: std.mem.Allocator, + svc: *zfin.DataService, + portfolio_path: []const u8, + since: ?Date, + until: ?Date, + color: bool, + verbosity: Verbosity, +) PrepareError!ReportContext { + const repo = git.findRepo(arena, portfolio_path) catch |err| { + if (verbosity == .verbose) { + switch (err) { + error.NotInGitRepo => cli.stderrPrint("Error: contributions requires portfolio.srf to be in a git repo.\n") catch {}, + error.GitUnavailable => cli.stderrPrint("Error: could not run 'git'. Is git installed and on PATH?\n") catch {}, + else => cli.stderrPrint("Error locating git repo.\n") catch {}, + } + } + return error.PrepareFailed; }; - // 2. Decide which snapshots to compare. - const status = try git.pathStatus(arena, repo.root, repo.rel_path); + const status = git.pathStatus(arena, repo.root, repo.rel_path) catch { + if (verbosity == .verbose) cli.stderrPrint("Error: could not determine git status of portfolio.srf.\n") catch {}; + return error.PrepareFailed; + }; if (status == .untracked) { - try cli.stderrPrint("Error: portfolio.srf is not tracked in git. Add and commit it first.\n"); - return; + if (verbosity == .verbose) cli.stderrPrint("Error: portfolio.srf is not tracked in git. Add and commit it first.\n") catch {}; + return error.PrepareFailed; } const dirty = status == .modified; - // 3. Pull both snapshots. - const before = if (dirty) - git.show(arena, repo.root, "HEAD", repo.rel_path) catch |err| { - switch (err) { - error.PathMissingInRev => try cli.stderrPrint("Error: portfolio.srf not present at HEAD.\n"), - else => try cli.stderrPrint("Error reading HEAD:portfolio.srf from git.\n"), - } - return; - } - else - git.show(arena, repo.root, "HEAD~1", repo.rel_path) catch |err| { - switch (err) { - error.PathMissingInRev => try cli.stderrPrint("Error: portfolio.srf not present at HEAD~1.\n"), - error.UnknownRevision => try cli.stderrPrint("Error: no prior commit to compare against (HEAD~1 does not exist).\n"), - else => try cli.stderrPrint("Error reading HEAD~1:portfolio.srf from git.\n"), - } - return; - }; + const endpoints = resolveEndpoints(arena, repo, since, until, dirty, verbosity) catch return error.PrepareFailed; - const after = if (dirty) - std.fs.cwd().readFileAlloc(arena, portfolio_path, 10 * 1024 * 1024) catch { - try cli.stderrPrint("Error reading working-copy portfolio file.\n"); - return; + // Pull both sides: before is always from git; after is either + // from git (at some revision) or from the working copy. + const before = git.show(arena, repo.root, endpoints.range.before_rev, repo.rel_path) catch |err| { + if (verbosity == .verbose) { + var buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ endpoints.range.before_rev, @errorName(err) }) catch "Error reading before-side portfolio.\n"; + cli.stderrPrint(msg) catch {}; } - else - git.show(arena, repo.root, "HEAD", repo.rel_path) catch { - try cli.stderrPrint("Error reading HEAD:portfolio.srf from git.\n"); - return; - }; - - // 4. Parse both. Portfolio uses the base allocator; its own deinit frees - // its internals independently of the arena. - var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch { - try cli.stderrPrint("Error parsing before-snapshot portfolio.\n"); - return; + return error.PrepareFailed; }; - defer before_pf.deinit(); + + const after = if (endpoints.range.after_rev) |rev| + git.show(arena, repo.root, rev, repo.rel_path) catch |err| { + if (verbosity == .verbose) { + var buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ rev, @errorName(err) }) catch "Error reading after-side portfolio.\n"; + cli.stderrPrint(msg) catch {}; + } + return error.PrepareFailed; + } + else + std.fs.cwd().readFileAlloc(arena, portfolio_path, 10 * 1024 * 1024) catch { + if (verbosity == .verbose) cli.stderrPrint("Error reading working-copy portfolio file.\n") catch {}; + return error.PrepareFailed; + }; + + var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch { + if (verbosity == .verbose) cli.stderrPrint("Error parsing before-snapshot portfolio.\n") catch {}; + return error.PrepareFailed; + }; + errdefer before_pf.deinit(); var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch { - try cli.stderrPrint("Error parsing after-snapshot portfolio.\n"); - return; + if (verbosity == .verbose) cli.stderrPrint("Error parsing after-snapshot portfolio.\n") catch {}; + return error.PrepareFailed; }; - defer after_pf.deinit(); + errdefer after_pf.deinit(); - // 5. Fetch current prices (cache-hit preferred) for DRIP/share-delta valuation. + // Fetch current prices (cache-hit preferred) for DRIP/share-delta valuation. var prices = std.StringHashMap(f64).init(arena); - // Union of stock symbols from both snapshots. var sym_set = std.StringHashMap(void).init(arena); for (before_pf.lots) |l| { if (l.security_type == .stock and !(l.price != null and l.ticker == null)) { - try sym_set.put(l.priceSymbol(), {}); + sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed; } } for (after_pf.lots) |l| { if (l.security_type == .stock and !(l.price != null and l.ticker == null)) { - try sym_set.put(l.priceSymbol(), {}); + 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| try syms.append(arena, k.*); + while (sit.next()) |k| syms.append(arena, k.*) catch return error.PrepareFailed; if (syms.items.len > 0) { var load_result = cli.loadPortfolioPrices(svc, syms.items, &.{}, false, color); defer load_result.deinit(); var pit = load_result.prices.iterator(); while (pit.next()) |entry| { - try prices.put(entry.key_ptr.*, entry.value_ptr.*); + prices.put(entry.key_ptr.*, entry.value_ptr.*) catch return error.PrepareFailed; } } - // 6. Compute the diff and print the report. The Report's backing memory - // lives in the arena; no explicit deinit needed. - const report = try computeReport(arena, before_pf.lots, after_pf.lots, &prices, fmt.todayDate()); + const report = computeReport(arena, before_pf.lots, after_pf.lots, &prices, fmt.todayDate()) catch { + if (verbosity == .verbose) cli.stderrPrint("Error computing contributions diff.\n") catch {}; + return error.PrepareFailed; + }; - try printReport(out, &report, dirty, color); - try out.flush(); + return .{ + .endpoints = endpoints, + .before_pf = before_pf, + .after_pf = after_pf, + .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 `computeAttribution` 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( + arena: std.mem.Allocator, + repo: git.RepoInfo, + since: ?Date, + until: ?Date, + dirty: bool, + verbosity: Verbosity, +) !Endpoints { + const range = git.resolveCommitRange(arena, repo, since, until, dirty) catch |err| { + if (verbosity == .verbose) { + switch (err) { + error.NoCommitAtOrBefore => { + // Report which flag triggered it. When both are set + // we can't easily tell from here; message covers both. + var since_buf: [10]u8 = undefined; + const since_str = if (since) |s| s.format(&since_buf) else "(unset)"; + 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, since_str }) catch "Error: no commit at or before requested date.\n"; + try cli.stderrPrint(msg); + }, + else => { + try cli.stderrPrint("Error resolving commit range: "); + try cli.stderrPrint(@errorName(err)); + try cli.stderrPrint("\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 buildLabel(arena, range, since, until, 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 (since != null and until != null and verbosity == .verbose) { + if (range.after_rev) |after_rev| { + if (std.mem.eql(u8, range.before_rev, after_rev)) { + try cli.stderrPrint("Warning: --since and --until resolve to the same commit; no changes to report.\n"); + } + } + } + + return .{ .range = range, .label = label }; +} + +/// 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 = since.?.format(&since_buf); + + if (until) |until_date| { + var until_buf: [10]u8 = undefined; + const until_str = until_date.format(&until_buf); + 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, + + pub fn total(self: AttributionSummary) f64 { + return self.new_contributions + self.drip; + } +}; + +/// Run the contributions pipeline over a date 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. +pub fn computeAttribution( + allocator: std.mem.Allocator, + svc: *zfin.DataService, + portfolio_path: []const u8, + since: ?Date, + until: ?Date, + color: bool, +) ?AttributionSummary { + // `--until` without `--since` is ambiguous; caller is expected to + // enforce this at the entry point, but guard here too — the + // prepareReport path sends it through `git.resolveCommitRange` + // which asserts the invariant. + if (since == null and until != null) return null; + + var arena_state = std.heap.ArenaAllocator.init(allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var ctx = prepareReport(allocator, arena, svc, portfolio_path, since, until, color, .silent) catch return null; + defer ctx.deinit(); + + // Aggregate. Classification logic matches the full-report sections: + // - New contributions: new_stock + new_cash + new_cd + new_option + // - 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. + 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 => new_contributions += c.value(), + .new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(), + else => {}, + }; + + return .{ .new_contributions = new_contributions, .drip = drip }; } // ── Git discovery / invocation ─────────────────────────────── @@ -448,7 +720,7 @@ fn computeReport( // ── Output ─────────────────────────────────────────────────── -fn printReport(out: *std.Io.Writer, report: *const Report, dirty: bool, color: bool) !void { +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; @@ -460,11 +732,9 @@ fn printReport(out: *std.Io.Writer, report: *const Report, dirty: bool, color: b try out.writeAll("Portfolio contributions report\n"); try cli.reset(out, color); try cli.setFg(out, color, mut_color); - if (dirty) { - try out.writeAll(" Comparing working copy against HEAD\n\n"); - } else { - try out.writeAll(" Working tree clean — comparing HEAD~1 against HEAD\n\n"); - } + 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. @@ -656,24 +926,16 @@ fn printReport(out: *std.Io.Writer, report: *const Report, dirty: bool, color: b fn printSection(out: *std.Io.Writer, title: []const u8, color: bool, hdr: [3]u8) !void { try cli.setBold(out, color); - try cli.setFg(out, color, hdr); - try out.writeAll("== "); - try out.writeAll(title); - try out.writeAll(" ==\n"); - try cli.reset(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.setFg(out, color, muted); - try out.writeAll(" (none)\n"); - try cli.reset(out, color); + try cli.printFg(out, color, muted, " (none)\n", .{}); } fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void { var buf: [32]u8 = undefined; - try cli.setFg(out, color, hdr); - try out.print(" {s}: {s}\n", .{ label, fmt.fmtMoneyAbs(&buf, v) }); - try cli.reset(out, color); + try cli.printFg(out, color, hdr, " {s}: {s}\n", .{ label, fmt.fmtMoneyAbs(&buf, v) }); } fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !void { @@ -687,14 +949,10 @@ fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !voi const acct = if (c.account.len == 0) "(no account)" else c.account; try out.print(" {s:<14}{s:<24}", .{ c.symbol, acct }); if (c.security_type == .cash) { - try cli.setFg(out, color, pos); - try out.print(" {s}", .{val_str}); - try cli.reset(out, color); + try cli.printFg(out, color, pos, " {s}", .{val_str}); } else { try out.print(" {s} shares × {s} = ", .{ share_str, price_str }); - try cli.setFg(out, color, pos); - try out.print("{s}", .{val_str}); - try cli.reset(out, color); + try cli.printFg(out, color, pos, "{s}", .{val_str}); } try out.writeAll("\n"); } @@ -718,9 +976,7 @@ fn printCdLine(out: *std.Io.Writer, c: Change, implied_interest: ?f64, color: bo }); if (implied_interest) |i| { var int_buf: [32]u8 = undefined; - try cli.setFg(out, color, cli.CLR_POSITIVE); - try out.print(" {s:<14}{s:<24} implied interest: {s}\n", .{ "", "", fmt.fmtMoneyAbs(&int_buf, i) }); - try cli.reset(out, color); + try cli.printFg(out, color, cli.CLR_POSITIVE, " {s:<14}{s:<24} implied interest: {s}\n", .{ "", "", fmt.fmtMoneyAbs(&int_buf, i) }); } } @@ -730,17 +986,13 @@ fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, col const acct = if (c.account.len == 0) "(no account)" else c.account; const sign = if (v >= 0) "+" else "-"; try out.print(" {s:<14}{s:<24} cash ", .{ c.symbol, acct }); - try cli.setGainLoss(out, color, v); - try out.print("{s}{s}", .{ sign, fmt.fmtMoneyAbs(&val_buf, @abs(v)) }); - try cli.reset(out, color); + try cli.printGainLoss(out, color, v, "{s}{s}", .{ sign, fmt.fmtMoneyAbs(&val_buf, @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)) { var face_buf: [32]u8 = undefined; - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" (may include CD maturity of {s})", .{fmt.fmtMoneyAbs(&face_buf, o.face_value)}); - try cli.reset(out, color); + try cli.printFg(out, color, cli.CLR_MUTED, " (may include CD maturity of {s})", .{fmt.fmtMoneyAbs(&face_buf, o.face_value)}); break; } } @@ -751,14 +1003,12 @@ fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) var old_buf: [32]u8 = undefined; var new_buf: [32]u8 = undefined; const acct = if (c.account.len == 0) "(no account)" else c.account; - try cli.setFg(out, color, muted); - try out.print(" {s:<14}{s:<24} price {s} → {s}\n", .{ + try cli.printFg(out, color, muted, " {s:<14}{s:<24} price {s} → {s}\n", .{ c.symbol, acct, fmt.fmtMoneyAbs(&old_buf, c.old_price), fmt.fmtMoneyAbs(&new_buf, c.new_price), }); - try cli.reset(out, color); } fn printFlaggedLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !void { @@ -1102,3 +1352,51 @@ test "computeReport: per-account totals separate drip_confirmed from rollup" { 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=`, +// which requires a real repo and is covered by `src/git.zig` tests +// plus manual smoke-testing. + +test "resolveEndpoints: legacy dirty → HEAD vs working copy" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" }; + + const eps = try resolveEndpoints(arena_state.allocator(), repo, 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(); + const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" }; + + const eps = try resolveEndpoints(arena_state.allocator(), repo, 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")); +} diff --git a/src/commands/divs.zig b/src/commands/divs.zig index 6a4164d..3c363ae 100644 --- a/src/commands/divs.zig +++ b/src/commands/divs.zig @@ -3,7 +3,7 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; -pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { +pub fn run(svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { const result = svc.getDividends(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); @@ -14,7 +14,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const return; }, }; - defer zfin.Dividend.freeSlice(allocator, result.data); + defer result.deinit(); if (result.source == .cached) try cli.stderrPrint("(using cached dividend data)\n"); diff --git a/src/commands/earnings.zig b/src/commands/earnings.zig index 81970b1..2fd9f8d 100644 --- a/src/commands/earnings.zig +++ b/src/commands/earnings.zig @@ -3,7 +3,7 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; -pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { +pub fn run(svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { const result = svc.getEarnings(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint("Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n"); @@ -14,7 +14,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const return; }, }; - defer allocator.free(result.data); + defer result.deinit(); // Sort newest-first — the first row is the most recent quarter, which // is the dominant query. Matches `git log` / `ls -lt` / `last` defaults diff --git a/src/commands/etf.zig b/src/commands/etf.zig index 170031f..241150e 100644 --- a/src/commands/etf.zig +++ b/src/commands/etf.zig @@ -3,7 +3,7 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; -pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { +pub fn run(svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { const result = svc.getEtfProfile(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); @@ -16,19 +16,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const }; const profile = result.data; - defer { - if (profile.holdings) |h| { - for (h) |holding| { - if (holding.symbol) |s| allocator.free(s); - allocator.free(holding.name); - } - allocator.free(h); - } - if (profile.sectors) |s| { - for (s) |sec| allocator.free(sec.name); - allocator.free(s); - } - } + defer result.deinit(); if (result.source == .cached) try cli.stderrPrint("(using cached ETF profile)\n"); diff --git a/src/commands/history.zig b/src/commands/history.zig index c2b8b45..eeea87d 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -34,7 +34,7 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const atomic = @import("../atomic.zig"); const timeline = @import("../analytics/timeline.zig"); -const history_io = @import("../history.zig"); +const history = @import("../history.zig"); const snapshot_model = @import("../models/snapshot.zig"); const view = @import("../views/history.zig"); const fmt = cli.fmt; @@ -114,7 +114,7 @@ pub fn run( out: *std.Io.Writer, ) !void { if (args.len > 0 and args[0].len > 0 and args[0][0] != '-') { - try runSymbol(allocator, svc, args[0], color, out); + try runSymbol(svc, args[0], color, out); return; } @@ -135,7 +135,6 @@ pub fn run( // ── Symbol mode (legacy) ───────────────────────────────────── fn runSymbol( - allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, @@ -151,7 +150,7 @@ fn runSymbol( return; }, }; - defer allocator.free(result.data); + defer result.deinit(); if (result.source == .cached) try cli.stderrPrint("(using cached data)\n"); @@ -201,7 +200,7 @@ fn runPortfolio( color: bool, out: *std.Io.Writer, ) !void { - var tl = try history_io.loadTimeline(allocator, portfolio_path); + var tl = try history.loadTimeline(allocator, portfolio_path); defer tl.deinit(); if (opts.rebuild_rollup) { diff --git a/src/commands/options.zig b/src/commands/options.zig index f4ab3f0..8b889ab 100644 --- a/src/commands/options.zig +++ b/src/commands/options.zig @@ -3,7 +3,7 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; -pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void { +pub fn run(svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void { const result = svc.getOptions(symbol) catch |err| switch (err) { zfin.DataError.FetchFailed => { try cli.stderrPrint("Error fetching options data from CBOE.\n"); @@ -15,14 +15,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const }, }; const ch = result.data; - defer { - for (ch) |chain| { - allocator.free(chain.underlying_symbol); - allocator.free(chain.calls); - allocator.free(chain.puts); - } - allocator.free(ch); - } + defer result.deinit(); if (result.source == .cached) try cli.stderrPrint("(using cached options data)\n"); diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 66b8671..7645479 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -4,75 +4,160 @@ /// - Benchmark comparison table (SPY/AGG vs portfolio weighted returns) /// - Conservative weighted return estimate /// - Safe withdrawal amounts at multiple horizons and confidence levels +/// +/// When `as_of` is non-null, the same output is produced against a +/// historical snapshot instead of the live portfolio. See +/// `src/views/projections.zig:loadProjectionContextAsOf`. const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; +const Date = zfin.Date; const performance = @import("../analytics/performance.zig"); const projections = @import("../analytics/projections.zig"); const benchmark = @import("../analytics/benchmark.zig"); const valuation = @import("../analytics/valuation.zig"); const view = @import("../views/projections.zig"); +const history = @import("../history.zig"); /// Hardcoded benchmark symbols (configurable in a future version). const stock_benchmark = "SPY"; const bond_benchmark = "AGG"; -pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, events_enabled: bool, color: bool, out: *std.Io.Writer) !void { - var loaded = cli.loadPortfolio(allocator, file_path) orelse return; - defer loaded.deinit(allocator); +/// How an as-of date resolved against the history directory. The CLI +/// uses this to render a single header that tells the user what +/// actually got loaded (exact hit, nearest-earlier, or straight-up +/// "no snapshot available"). +const AsOfResolution = struct { + /// The requested date, as parsed by the caller. + requested: Date, + /// The date that was actually loaded. Differs from `requested` + /// when we auto-snapped to the nearest-earlier snapshot. + actual: Date, +}; - const portfolio = loaded.portfolio; - const positions = loaded.positions; - const syms = loaded.syms; - - // Build prices from cache - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - for (positions) |pos| { - if (pos.shares <= 0) continue; - if (svc.getCachedCandles(pos.symbol)) |cs| { - defer allocator.free(cs); - if (cs.len > 0) { - try prices.put(pos.symbol, cs[cs.len - 1].close); - } - } - } - - // Build portfolio summary - var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc) catch |err| switch (err) { - error.NoAllocations, error.SummaryFailed => { - try cli.stderrPrint("Error computing portfolio summary.\n"); - return; - }, - else => return err, - }; - defer pf_data.deinit(allocator); - - // Build projection context (loads config, metadata, computes everything) - const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0; +pub fn run( + allocator: std.mem.Allocator, + svc: *zfin.DataService, + file_path: []const u8, + events_enabled: bool, + as_of: ?Date, + color: bool, + out: *std.Io.Writer, +) !void { + // Single arena for all view/render allocations. Same lifetime + // regardless of live vs. as-of path. var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const va = arena_state.allocator(); - const ctx = try view.loadProjectionContext( - va, - file_path[0..dir_end], - pf_data.summary.allocations, - pf_data.summary.total_value, - portfolio.totalCash(), - portfolio.totalCdFaceValue(), - svc, - events_enabled, - ); + // portfolio_dir is the directory component of file_path, ending + // in a separator (for the downstream `{s}projections.srf` join). + const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0; + const portfolio_dir = file_path[0..dir_end]; + + // Build the context via either the live or as-of pipeline. Both + // produce a `ProjectionContext`; from that point on rendering is + // identical. + var ctx: view.ProjectionContext = undefined; + var resolution: ?AsOfResolution = null; + // Snapshot must outlive the context when on the as-of path because + // `ctx.allocations` borrow their symbol strings from the snapshot's + // backing buffer. Keep this declared at the outer scope so the + // defer runs at the end of `run`. + var snap_bundle: ?history.LoadedSnapshot = null; + defer if (snap_bundle) |*s| s.deinit(allocator); + + if (as_of) |requested_date| { + resolution = resolveAsOfSnapshot(va, file_path, requested_date) catch |err| switch (err) { + error.NoSnapshot => return, + else => return err, + }; + const hist_dir = try history.deriveHistoryDir(va, file_path); + snap_bundle = try history.loadSnapshotAt(allocator, hist_dir, resolution.?.actual); + + ctx = try view.loadProjectionContextAsOf( + va, + portfolio_dir, + &snap_bundle.?.snap, + resolution.?.actual, + svc, + events_enabled, + ); + } else { + var loaded = cli.loadPortfolio(allocator, file_path) orelse return; + defer loaded.deinit(allocator); + const portfolio = loaded.portfolio; + const positions = loaded.positions; + const syms = loaded.syms; + + // Prices from cache — matches pre-as-of behavior exactly. + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + for (positions) |pos| { + if (pos.shares <= 0) continue; + if (svc.getCachedCandles(pos.symbol)) |cs| { + defer cs.deinit(); + if (cs.data.len > 0) { + try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); + } + } + } + + var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc) catch |err| switch (err) { + error.NoAllocations, error.SummaryFailed => { + try cli.stderrPrint("Error computing portfolio summary.\n"); + return; + }, + else => return err, + }; + defer pf_data.deinit(allocator); + + ctx = try view.loadProjectionContext( + va, + portfolio_dir, + pf_data.summary.allocations, + pf_data.summary.total_value, + portfolio.totalCash(), + portfolio.totalCdFaceValue(), + svc, + events_enabled, + ); + } + const horizons = ctx.config.getHorizons(); const confidence_levels = ctx.config.getConfidenceLevels(); const comparison = ctx.comparison; + try out.print("\n", .{}); try cli.setBold(out, color); - try out.print("Projections ({s})\n", .{file_path}); + if (resolution) |r| { + var buf: [10]u8 = undefined; + try out.print("Projections (as of {s})\n", .{r.actual.format(&buf)}); + } else { + try out.print("Projections ({s})\n", .{file_path}); + } try cli.reset(out, color); - try out.print("========================================\n\n", .{}); + try out.print("========================================\n", .{}); + + // If auto-snapped, print a muted note so the user knows the + // requested date wasn't an exact hit. + if (resolution) |r| { + if (r.actual.days != r.requested.days) { + const diff = r.requested.days - r.actual.days; + var req_buf: [10]u8 = undefined; + var act_buf: [10]u8 = undefined; + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("(requested {s}; nearest snapshot: {s}, {d} day{s} earlier)\n", .{ + r.requested.format(&req_buf), + r.actual.format(&act_buf), + diff, + fmt.dayPlural(diff), + }); + try cli.reset(out, color); + } + } + try out.print("\n", .{}); // Header row try cli.setFg(out, color, cli.CLR_MUTED); @@ -215,11 +300,14 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co try cli.reset(out, color); } - // Life events summary + // Life events summary — as-of mode uses ages-as-of-as_of; live + // mode uses current ages. `currentAgesAsOf(today)` returns the + // current ages, so this unifies both paths. { const events = ctx.config.getEvents(); if (events.len > 0) { - const ages = ctx.config.currentAges(); + const ages_ref_date = if (resolution) |r| r.actual else fmt.todayDate(); + const ages = ctx.config.currentAgesAsOf(ages_ref_date); try out.print("\n", .{}); try cli.setBold(out, color); try out.print("Life Events\n", .{}); @@ -236,6 +324,56 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co try out.print("\n", .{}); } +/// Resolve the user's requested as-of date against the history directory. +/// +/// Thin adapter over `history.resolveSnapshotDate` — the shared pure +/// resolver owns exact-then-fallback logic. This wrapper maps the +/// error set to `error.NoSnapshot` and surfaces user-visible stderr +/// messages (including the "Earliest available (later than requested)" +/// hint, which the TUI doesn't need). +/// +/// Arena-allocates the intermediate `hist_dir` + filename strings; +/// pass a short-lived arena as `va`. +fn resolveAsOfSnapshot( + va: std.mem.Allocator, + file_path: []const u8, + requested: Date, +) !AsOfResolution { + const hist_dir = try history.deriveHistoryDir(va, file_path); + + const resolved = history.resolveSnapshotDate(va, hist_dir, requested) catch |err| switch (err) { + error.NoSnapshotAtOrBefore => { + var req_buf: [10]u8 = undefined; + const req_str = requested.format(&req_buf); + const msg = std.fmt.allocPrint(va, "No snapshot at or before {s}.\n", .{req_str}) catch "No snapshot at or before the requested date.\n"; + try cli.stderrPrint(msg); + // Second look at the nearest table for the "later available" + // hint. Cheap (filesystem scan, same dir). + const nearest = history.findNearestSnapshot(hist_dir, requested) catch { + try cli.stderrPrint("No snapshots in history/ — run `zfin snapshot` to create one.\n"); + return error.NoSnapshot; + }; + if (nearest.later) |later| { + var later_buf: [10]u8 = undefined; + const later_str = later.format(&later_buf); + const later_msg = std.fmt.allocPrint(va, "Earliest available: {s} (later than requested).\n", .{later_str}) catch "A later snapshot exists but was not used.\n"; + try cli.stderrPrint(later_msg); + } else { + try cli.stderrPrint("No snapshots in history/ — run `zfin snapshot` to create one.\n"); + } + return error.NoSnapshot; + }, + else => |e| { + try cli.stderrPrint("Error resolving snapshot: "); + try cli.stderrPrint(@errorName(e)); + try cli.stderrPrint("\n"); + return error.NoSnapshot; + }, + }; + + return .{ .requested = resolved.requested, .actual = resolved.actual }; +} + /// Write a return row using the view model, applying StyleIntent colors. fn writeReturnRow(out: *std.Io.Writer, color: bool, row: view.ReturnRow) !void { try out.print("{s: <32}", .{row.label}); @@ -257,3 +395,243 @@ fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usi } try cli.reset(out, color); } + +// ── Tests ──────────────────────────────────────────────────── +// +// The projections simulation and rendering are covered by the +// view-model tests in `src/views/projections.zig` and the analytics +// tests in `src/analytics/`. These tests focus on the CLI-surface +// behaviour that `run` is responsible for: as-of snapshot resolution, +// exact/nearest/miss branching, and error reporting. + +const testing = std.testing; +const snapshot_model = @import("../models/snapshot.zig"); +const snapshot_cmd = @import("snapshot.zig"); + +fn makeTestSvc() zfin.DataService { + const config = zfin.Config{ .cache_dir = "/tmp" }; + return zfin.DataService.init(testing.allocator, config); +} + +fn writeFixtureSnapshot( + dir: std.fs.Dir, + allocator: std.mem.Allocator, + filename: []const u8, + as_of: Date, + liquid: f64, +) !void { + const lots = [_]snapshot_model.LotRow{ + .{ + .kind = "lot", + .symbol = "VTI", + .lot_symbol = "VTI", + .account = "Roth", + .security_type = "Stock", + .shares = 100, + .open_price = 200, + .cost_basis = 20_000, + .value = liquid, + .price = liquid / 100, + .quote_date = as_of, + }, + }; + const totals = [_]snapshot_model.TotalRow{ + .{ .kind = "total", .scope = "net_worth", .value = liquid }, + .{ .kind = "total", .scope = "liquid", .value = liquid }, + .{ .kind = "total", .scope = "illiquid", .value = 0 }, + }; + const snap: snapshot_model.Snapshot = .{ + .meta = .{ + .kind = "meta", + .snapshot_version = 1, + .as_of_date = as_of, + .captured_at = 1_745_222_400, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = @constCast(&totals), + .tax_types = &.{}, + .accounts = &.{}, + .lots = @constCast(&lots), + }; + const rendered = try snapshot_cmd.renderSnapshot(allocator, snap); + defer allocator.free(rendered); + try dir.writeFile(.{ .sub_path = filename, .data = rendered }); +} + +/// Build a portfolio path inside `tmp` and return the joined string. +/// Caller owns the returned buffer. +fn makeTestPortfolioPath(tmp: *std.testing.TmpDir, allocator: std.mem.Allocator) ![]u8 { + const dir_path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(dir_path); + return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" }); +} + +test "resolveAsOfSnapshot: exact match returns actual == requested" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath("history"); + var hist_dir = try tmp.dir.openDir("history", .{}); + defer hist_dir.close(); + + const d = Date.fromYmd(2026, 3, 13); + try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-03-13-portfolio.srf", d, 1_000_000); + + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const res = try resolveAsOfSnapshot(arena.allocator(), pf, d); + try testing.expect(res.actual.eql(d)); + try testing.expect(res.requested.eql(d)); +} + +test "resolveAsOfSnapshot: no exact match snaps to earlier" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath("history"); + var hist_dir = try tmp.dir.openDir("history", .{}); + defer hist_dir.close(); + + const earlier = Date.fromYmd(2026, 3, 12); + try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-03-12-portfolio.srf", earlier, 1_000_000); + + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const requested = Date.fromYmd(2026, 3, 13); + const res = try resolveAsOfSnapshot(arena.allocator(), pf, requested); + try testing.expect(res.actual.eql(earlier)); + try testing.expect(res.requested.eql(requested)); + try testing.expect(!res.actual.eql(res.requested)); +} + +test "resolveAsOfSnapshot: no earlier snapshot returns NoSnapshot" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath("history"); + var hist_dir = try tmp.dir.openDir("history", .{}); + defer hist_dir.close(); + + // Only a later snapshot exists — can't satisfy an earlier request. + const later = Date.fromYmd(2026, 4, 1); + try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-04-01-portfolio.srf", later, 1_000_000); + + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const requested = Date.fromYmd(2026, 3, 13); + const result = resolveAsOfSnapshot(arena.allocator(), pf, requested); + try testing.expectError(error.NoSnapshot, result); +} + +test "resolveAsOfSnapshot: empty history dir returns NoSnapshot" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath("history"); + + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const requested = Date.fromYmd(2026, 3, 13); + const result = resolveAsOfSnapshot(arena.allocator(), pf, requested); + try testing.expectError(error.NoSnapshot, result); +} + +test "run: as_of with no snapshots returns without error (stderr-only)" { + // No history dir at all. `run` prints a stderr hint via + // `resolveAsOfSnapshot` and returns — should NOT propagate the + // error to the caller (exit code stays 0 from the CLI dispatch). + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [4096]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + const d = Date.fromYmd(2026, 3, 13); + try run(testing.allocator, &svc, pf, false, d, false, &stream); + + // No body output because the resolution failed — the stderr + // message is swallowed by `cli.stderrPrint` and doesn't land in + // `stream`. This guarantees the error-path returns cleanly. + const out = stream.buffered(); + try testing.expectEqual(@as(usize, 0), out.len); +} + +test "run: as_of with matching snapshot produces body output" { + // End-to-end smoke test. With no cached candles, benchmark rows + // will be `--` and portfolio returns will be empty, but the + // rendering pipeline should still produce a complete header + + // tables without panicking. + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + + try tmp.dir.makePath("history"); + var hist_dir = try tmp.dir.openDir("history", .{}); + defer hist_dir.close(); + + const d = Date.fromYmd(2026, 3, 13); + try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-03-13-portfolio.srf", d, 1_000_000); + + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [32_768]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + try run(testing.allocator, &svc, pf, false, d, false, &stream); + + const out = stream.buffered(); + // Header should call out the as-of date explicitly. + try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-13") != null); + // Benchmark + withdrawal tables still render even with missing candles. + try testing.expect(std.mem.indexOf(u8, out, "Safe Withdrawal") != null); + try testing.expect(std.mem.indexOf(u8, out, "Terminal Portfolio Value") != null); +} + +test "run: as_of auto-snap surfaces muted 'nearest' note" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + + try tmp.dir.makePath("history"); + var hist_dir = try tmp.dir.openDir("history", .{}); + defer hist_dir.close(); + + const actual = Date.fromYmd(2026, 3, 12); + try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-03-12-portfolio.srf", actual, 1_000_000); + + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [32_768]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + const requested = Date.fromYmd(2026, 3, 13); + try run(testing.allocator, &svc, pf, false, requested, false, &stream); + + const out = stream.buffered(); + try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null); + try testing.expect(std.mem.indexOf(u8, out, "(requested 2026-03-13") != null); + try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2026-03-12") != null); + // 1 day earlier → singular "day", not "days" + try testing.expect(std.mem.indexOf(u8, out, "1 day earlier") != null); +} diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 36540df..2f13268 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -26,7 +26,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const return; }, }; - defer allocator.free(candle_result.data); + defer candle_result.deinit(); const candles = candle_result.data; // Fetch real-time quote via DataService diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index 1a3a298..0c5b2de 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -210,7 +210,7 @@ pub fn run( // // Not applied in auto mode: auto mode's as_of already comes from // cache mode and is guaranteed to be a trading day. - if (as_of_override != null and !hasAnyTradingDayCandle(allocator, svc, syms, as_of)) { + if (as_of_override != null and !hasAnyTradingDayCandle(svc, syms, as_of)) { var date_buf: [10]u8 = undefined; var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( @@ -229,8 +229,8 @@ pub fn run( for (syms) |sym| { if (svc.getCachedCandles(sym)) |cs| { - defer allocator.free(cs); - if (zfin.valuation.candleCloseOnOrBefore(cs, as_of)) |cad| { + defer cs.deinit(); + if (zfin.valuation.candleCloseOnOrBefore(cs.data, as_of)) |cad| { try symbol_prices.put(sym, cad); } } @@ -486,7 +486,6 @@ pub fn probeFreshAsOfDate( /// symbols. The absence of US equity candles across the board is what /// signals a non-trading day for our purposes. pub fn hasAnyTradingDayCandle( - allocator: std.mem.Allocator, svc: *zfin.DataService, symbols: []const []const u8, date: Date, @@ -494,15 +493,15 @@ pub fn hasAnyTradingDayCandle( for (symbols) |sym| { if (portfolio_mod.isMoneyMarketSymbol(sym)) continue; const cs = svc.getCachedCandles(sym) orelse continue; - defer allocator.free(cs); + defer cs.deinit(); // Linear scan from the end — recent dates are where `date` is // most likely to land for a backfill. - var i: usize = cs.len; + var i: usize = cs.data.len; while (i > 0) { i -= 1; - if (cs[i].date.eql(date)) return true; + if (cs.data[i].date.eql(date)) return true; // Candles are sorted ascending; bail early once we're past. - if (cs[i].date.lessThan(date)) break; + if (cs.data[i].date.lessThan(date)) break; } } return false; @@ -523,8 +522,8 @@ pub fn collectQuoteDates( const is_mm = portfolio_mod.isMoneyMarketSymbol(sym); var last_date: ?Date = null; if (svc.getCachedCandles(sym)) |cs| { - defer allocator.free(cs); - if (cs.len > 0) last_date = cs[cs.len - 1].date; + defer cs.deinit(); + if (cs.data.len > 0) last_date = cs.data[cs.data.len - 1].date; } list[idx] = .{ .symbol = sym, .last_date = last_date, .is_money_market = is_mm }; } diff --git a/src/commands/splits.zig b/src/commands/splits.zig index 0ee5afc..b237d5e 100644 --- a/src/commands/splits.zig +++ b/src/commands/splits.zig @@ -3,7 +3,7 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; -pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { +pub fn run(svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { const result = svc.getSplits(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); @@ -14,7 +14,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const return; }, }; - defer allocator.free(result.data); + defer result.deinit(); if (result.source == .cached) try cli.stderrPrint("(using cached split data)\n"); diff --git a/src/compare.zig b/src/compare.zig index b257389..47e3c05 100644 --- a/src/compare.zig +++ b/src/compare.zig @@ -2,14 +2,25 @@ //! //! The CLI + TUI shared "compose two points in time" module. Loads a //! snapshot into a `SnapshotSide` (aggregated per-symbol holdings + -//! liquid total), and provides the aggregation primitives that turn +//! liquid total), and provides the aggregation primitive that turns //! either a parsed snapshot or the live portfolio into the -//! `view.HoldingMap` shape the view model consumes. +//! `view.HoldingMap` shape the compare view consumes. //! //! Responsibility split: -//! - `src/history.zig` — snapshot IO (read file at a date) -//! - `src/compare.zig` — semantic composition (snapshot → -//! aggregated side; live aggregation) +//! - `src/history.zig` — snapshot IO + pure-domain +//! aggregation (`liquidFromSnapshot`, +//! `aggregateSnapshotAllocations` for +//! the projection view) +//! - `src/compare.zig` — compare-feature-specific +//! composition: loads a snapshot into +//! a compare-shaped `SnapshotSide` +//! (`aggregateSnapshotStocks`), +//! plus a live-portfolio aggregation +//! mirroring the same shape. +//! Lives here (not in `history.zig`) +//! because its output type is the +//! compare view's `HoldingMap` — +//! moving it would invert layers. //! - `src/views/compare.zig` — pure view model (build CompareView //! from two holdings maps + totals) //! - `src/commands/compare.zig` — CLI dispatch + live-side pipeline @@ -23,7 +34,7 @@ const std = @import("std"); const zfin = @import("root.zig"); -const history_io = @import("history.zig"); +const history = @import("history.zig"); const snapshot_model = @import("models/snapshot.zig"); const fmt = @import("format.zig"); const view = @import("views/compare.zig"); @@ -41,7 +52,7 @@ pub const Date = zfin.Date; pub const SnapshotSide = struct { map: view.HoldingMap, liquid: f64, - loaded: history_io.LoadedSnapshot, + loaded: history.LoadedSnapshot, pub fn deinit(self: *SnapshotSide, allocator: std.mem.Allocator) void { self.map.deinit(); @@ -60,7 +71,7 @@ pub fn loadSnapshotSide( hist_dir: []const u8, date: Date, ) !SnapshotSide { - var loaded = try history_io.loadSnapshotAt(allocator, hist_dir, date); + var loaded = try history.loadSnapshotAt(allocator, hist_dir, date); errdefer loaded.deinit(allocator); var map: view.HoldingMap = .init(allocator); @@ -69,18 +80,23 @@ pub fn loadSnapshotSide( return .{ .map = map, - .liquid = liquidFromSnapshot(&loaded.snap), + .liquid = history.liquidFromSnapshot(&loaded.snap), .loaded = loaded, }; } -// ── Aggregation helpers ────────────────────────────────────── +// ── Stock aggregation (compare-view shape) ─────────────────── /// Walk a snapshot's lot rows, filter to `security_type == "Stock"`, /// and group by symbol into `out_map`. Shares are summed; price is /// taken from the first lot seen (all stock lots of a symbol share /// the same `price` field in a given snapshot). /// +/// Lives here rather than in `history.zig` because it emits a +/// `view.HoldingMap` — a compare-view-shaped type. The projection- +/// shaped `aggregateSnapshotAllocations` (which emits the lower-level +/// `valuation.Allocation`) lives in `history.zig`. +/// /// `out_map` keys borrow from the snapshot's backing byte buffer. /// Caller must keep the snapshot alive as long as the map is used. pub fn aggregateSnapshotStocks( @@ -99,6 +115,8 @@ pub fn aggregateSnapshotStocks( } } +// ── Live-portfolio aggregation ─────────────────────────────── + /// Walk the live portfolio's stock lots, group by `priceSymbol()`, /// and look up the current price from `prices`. Mirrors the snapshot /// aggregation so the two sides are apples-to-apples. @@ -126,16 +144,6 @@ pub fn aggregateLiveStocks( } } -/// Find the `scope=="liquid"` total in a snapshot. Returns 0.0 if not -/// present (old snapshots from before the liquid/illiquid split — -/// shouldn't happen in practice). -pub fn liquidFromSnapshot(snap: *const snapshot_model.Snapshot) f64 { - for (snap.totals) |t| { - if (std.mem.eql(u8, t.scope, "liquid")) return t.value; - } - return 0.0; -} - // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; @@ -218,48 +226,6 @@ test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price try testing.expectEqual(@as(f64, 25), (map.get("MSFT") orelse unreachable).shares); } -test "liquidFromSnapshot: finds liquid scope" { - const totals = [_]snapshot_model.TotalRow{ - .{ .scope = "net_worth", .value = 1100 }, - .{ .scope = "liquid", .value = 1000 }, - .{ .scope = "illiquid", .value = 100 }, - }; - const snap = snapshot_model.Snapshot{ - .meta = .{ - .snapshot_version = 1, - .as_of_date = Date.fromYmd(2024, 3, 15), - .captured_at = 0, - .zfin_version = "test", - .stale_count = 0, - }, - .totals = @constCast(&totals), - .tax_types = &.{}, - .accounts = &.{}, - .lots = &.{}, - }; - try testing.expectEqual(@as(f64, 1000), liquidFromSnapshot(&snap)); -} - -test "liquidFromSnapshot: returns 0 when no liquid scope" { - const totals = [_]snapshot_model.TotalRow{ - .{ .scope = "net_worth", .value = 1100 }, - }; - const snap = snapshot_model.Snapshot{ - .meta = .{ - .snapshot_version = 1, - .as_of_date = Date.fromYmd(2024, 3, 15), - .captured_at = 0, - .zfin_version = "test", - .stale_count = 0, - }, - .totals = @constCast(&totals), - .tax_types = &.{}, - .accounts = &.{}, - .lots = &.{}, - }; - try testing.expectEqual(@as(f64, 0.0), liquidFromSnapshot(&snap)); -} - test "loadSnapshotSide: happy path builds a SnapshotSide with aggregated holdings" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); diff --git a/src/format.zig b/src/format.zig index e38f7d3..3f4486b 100644 --- a/src/format.zig +++ b/src/format.zig @@ -277,6 +277,13 @@ pub fn todayDate() Date { return .{ .days = days }; } +/// English pluralization for "(N day[s])" display suffixes. Returns +/// `""` for `n == 1`, `"s"` otherwise. Used across the CLI and TUI +/// anywhere a day-count is printed inline. +pub fn dayPlural(n: i32) []const u8 { + return if (n == 1) "" else "s"; +} + /// Return "LT" if held > 1 year from open_date to today, "ST" otherwise. pub fn capitalGainsIndicator(open_date: Date) []const u8 { const today = todayDate(); diff --git a/src/git.zig b/src/git.zig index 64332fd..8760b57 100644 --- a/src/git.zig +++ b/src/git.zig @@ -15,6 +15,7 @@ //! than inlining `std.process.Child.run` in the command module. const std = @import("std"); +const Date = @import("models/date.zig").Date; // ── Types ──────────────────────────────────────────────────── @@ -36,6 +37,9 @@ pub const Error = error{ PathMissingInRev, /// `git log` returned non-zero. GitLogFailed, + /// `resolveCommitRange` was asked for a `since` date with no commit + /// at or before it — nothing to diff against. + NoCommitAtOrBefore, OutOfMemory, }; @@ -59,11 +63,23 @@ pub const PathStatus = enum { /// Wall-clock time a commit touched a given path. pub const CommitTouch = struct { - commit: []const u8, // 40-char SHA + commit: []const u8, // commit hash (40 chars for SHA-1, 64 for SHA-256) /// Committer timestamp (Unix epoch seconds). timestamp: i64, }; +/// A before/after pair of git revisions to diff. +/// +/// `before_rev` is always a concrete revision (SHA or symbolic like +/// `HEAD~1`). `after_rev` is null when the caller wants the working +/// copy on the right-hand side; the caller reads the file directly +/// rather than going through `git show`. +pub const CommitRange = struct { + before_rev: []const u8, + /// null = working copy; non-null = a concrete git revision. + after_rev: ?[]const u8, +}; + // ── Implementation ─────────────────────────────────────────── /// Locate the git repository containing `path` and the path's position @@ -299,6 +315,122 @@ pub fn lastCommitTimestampForPath( return std.fmt.parseInt(i64, trimmed, 10) catch return null; } +/// Return the SHA of the most recent commit that touched `rel_path` at +/// or before `date_iso` (YYYY-MM-DD, inclusive end-of-day semantics via +/// `git log --until`). +/// +/// Returns null if no commit before `date_iso` touched `rel_path`. +/// Caller owns the returned string. +/// +/// Used by `zfin contributions --since ` / `--until ` to +/// resolve a date to the last commit that stamped a given snapshot of +/// the portfolio file. +pub fn commitAtOrBeforeDate( + allocator: std.mem.Allocator, + root: []const u8, + rel_path: []const u8, + date_iso: []const u8, +) Error!?[]const u8 { + // `git log --until=DATE` uses the end of the given date as the + // inclusive upper bound. Adding a "T23:59:59" suffix isn't + // necessary — git already interprets bare dates as "end of day". + const until_arg = try std.fmt.allocPrint(allocator, "--until={s}", .{date_iso}); + defer allocator.free(until_arg); + + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ + "git", "-C", root, + "log", "-1", "--format=%H", + until_arg, "--", rel_path, + }, + .max_output_bytes = 64 * 1024, + }) catch return error.GitUnavailable; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + switch (result.term) { + .Exited => |code| if (code != 0) return error.GitLogFailed, + else => return error.GitLogFailed, + } + + const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); + if (trimmed.len == 0) return null; + // Defensive: `git log --format=%H` emits the full commit hash and + // nothing else. Guard against stdout noise (e.g. a warning + // accidentally routed to stdout) by requiring the result to look + // like a hash — all hex, sensible length. SHA-1 is 40 chars, + // SHA-256 is 64; accept anything in that range or longer to stay + // forward-compatible with future git hash formats. + if (trimmed.len < 40) return error.GitLogFailed; + for (trimmed) |c| if (!std.ascii.isHex(c)) return error.GitLogFailed; + return try allocator.dupe(u8, trimmed); +} + +/// Resolve a before/after commit range for diffing `repo.rel_path`. +/// +/// Three modes selected by `since` / `until`: +/// +/// - `since == null` (legacy): no date window. +/// - `dirty == false`: before=`HEAD~1`, after=`HEAD` (review last commit). +/// - `dirty == true`: before=`HEAD`, after=working-copy. +/// - `since != null, until == null`: single cutoff. +/// - before = commit-at-or-before(since). +/// - `dirty == false`: after=`HEAD`. +/// - `dirty == true`: after=working-copy. +/// - `since != null, until != null`: date window between two commits. +/// - before = commit-at-or-before(since). +/// - after = commit-at-or-before(until). +/// +/// `until` without `since` is rejected via assertion — the window is +/// ambiguous without a starting point. The caller is responsible for +/// enforcing that at the argument-parsing layer. +/// +/// Returns `error.NoCommitAtOrBefore` when `since` or `until` resolves +/// to "no commit exists at or before this date". Callers decide how +/// to surface that to the user. +/// +/// Pure SHA-level output — no labels, no stderr side effects. All +/// allocations use `arena`. +pub fn resolveCommitRange( + arena: std.mem.Allocator, + repo: RepoInfo, + since: ?Date, + until: ?Date, + dirty: bool, +) Error!CommitRange { + std.debug.assert(!(since == null and until != null)); + + // Legacy path: no date window. HEAD~1..HEAD (clean) or + // HEAD..working-copy (dirty). Returns the symbolic revisions + // verbatim — callers feed them directly to `git show`. + if (since == null) { + return if (dirty) + .{ .before_rev = "HEAD", .after_rev = null } + else + .{ .before_rev = "HEAD~1", .after_rev = "HEAD" }; + } + + var since_buf: [10]u8 = undefined; + const since_str = since.?.format(&since_buf); + const since_sha = (try commitAtOrBeforeDate(arena, repo.root, repo.rel_path, since_str)) orelse + return error.NoCommitAtOrBefore; + + if (until) |until_date| { + var until_buf: [10]u8 = undefined; + const until_str = until_date.format(&until_buf); + const until_sha = (try commitAtOrBeforeDate(arena, repo.root, repo.rel_path, until_str)) orelse + return error.NoCommitAtOrBefore; + return .{ .before_rev = since_sha, .after_rev = until_sha }; + } + + // --since only: after side is HEAD (or working copy if dirty). + return if (dirty) + .{ .before_rev = since_sha, .after_rev = null } + else + .{ .before_rev = since_sha, .after_rev = "HEAD" }; +} + // ── Tests ──────────────────────────────────────────────────── // // These tests shell out to `git init` and friends in a tmp dir. They @@ -330,3 +462,93 @@ test "listCommitsTouching returns at least one commit for build.zig" { // Timestamps are plausible (after 2020). try std.testing.expect(commits[0].timestamp > 1_577_836_800); } + +test "commitAtOrBeforeDate returns a SHA for a past date" { + const allocator = std.testing.allocator; + const info = findRepo(allocator, "build.zig") catch return; + defer allocator.free(info.root); + defer allocator.free(info.rel_path); + + // Any date well after the repo's creation — commitAtOrBeforeDate + // should find the most recent commit touching build.zig. + const sha_opt = commitAtOrBeforeDate(allocator, info.root, info.rel_path, "2099-01-01") catch return; + try std.testing.expect(sha_opt != null); + const sha = sha_opt.?; + defer allocator.free(sha); + // Accept either SHA-1 (40) or SHA-256 (64) format. Git is + // gradually rolling out SHA-256; this test mustn't assume one. + try std.testing.expect(sha.len == 40 or sha.len == 64); + for (sha) |c| try std.testing.expect(std.ascii.isHex(c)); +} + +test "commitAtOrBeforeDate returns null for date before repo existed" { + const allocator = std.testing.allocator; + const info = findRepo(allocator, "build.zig") catch return; + defer allocator.free(info.root); + defer allocator.free(info.rel_path); + + // Pre-git — before any sensible project history. + const sha_opt = commitAtOrBeforeDate(allocator, info.root, info.rel_path, "1970-01-02") catch return; + try std.testing.expect(sha_opt == null); +} + +test "resolveCommitRange: legacy clean → HEAD~1..HEAD" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const repo: RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" }; + + const range = try resolveCommitRange(arena_state.allocator(), repo, null, null, false); + try std.testing.expectEqualStrings("HEAD~1", range.before_rev); + try std.testing.expectEqualStrings("HEAD", range.after_rev.?); +} + +test "resolveCommitRange: legacy dirty → HEAD..working-copy" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const repo: RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" }; + + const range = try resolveCommitRange(arena_state.allocator(), repo, null, null, true); + try std.testing.expectEqualStrings("HEAD", range.before_rev); + try std.testing.expect(range.after_rev == null); +} + +test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" { + const allocator = std.testing.allocator; + const info = findRepo(allocator, "build.zig") catch return; + defer allocator.free(info.root); + defer allocator.free(info.rel_path); + + var arena_state = std.heap.ArenaAllocator.init(allocator); + defer arena_state.deinit(); + + // Any date well after project start — resolves to latest commit. + const range = resolveCommitRange( + arena_state.allocator(), + info, + Date.fromYmd(2099, 1, 1), + null, + false, + ) catch return; + try std.testing.expect(range.before_rev.len >= 40); + try std.testing.expectEqualStrings("HEAD", range.after_rev.?); +} + +test "resolveCommitRange: --since with no earlier commit → NoCommitAtOrBefore" { + const allocator = std.testing.allocator; + const info = findRepo(allocator, "build.zig") catch return; + defer allocator.free(info.root); + defer allocator.free(info.rel_path); + + var arena_state = std.heap.ArenaAllocator.init(allocator); + defer arena_state.deinit(); + + // Before any commit exists in this repo. + const result = resolveCommitRange( + arena_state.allocator(), + info, + Date.fromYmd(1970, 1, 2), + null, + false, + ); + try std.testing.expectError(error.NoCommitAtOrBefore, result); +} diff --git a/src/history.zig b/src/history.zig index 66e8d14..7c9bef8 100644 --- a/src/history.zig +++ b/src/history.zig @@ -1,7 +1,9 @@ //! History IO — read `history/-portfolio.srf` files produced by -//! `zfin snapshot` back into typed `Snapshot` structs. +//! `zfin snapshot` back into typed `Snapshot` structs. Also the +//! pure-domain aggregation helpers that turn a parsed snapshot into +//! the shapes downstream views consume. //! -//! Two layers, both pure of rendering concerns: +//! Three layers, all pure of rendering concerns: //! //! - `parseSnapshotBytes(bytes)` — parse an SRF blob into a `Snapshot`. //! The snapshot's string fields slice directly into `bytes`, so the @@ -9,6 +11,11 @@ //! - `loadHistoryDir(dir)` — enumerate `*-portfolio.srf` in a directory //! and parse each. The returned `LoadedHistory` owns both the //! snapshots and their backing byte buffers as matched pairs. +//! - `liquidFromSnapshot(snap)`, `aggregateSnapshotAllocations(...)` — +//! pure-domain transforms on a parsed snapshot, used by the +//! projection view. The compare-view-specific aggregator +//! (`aggregateSnapshotStocks`, producing a view-layer `HoldingMap`) +//! lives in `src/compare.zig` to avoid inverting the layer direction. //! //! The snapshot reader is discriminator-driven: every record must carry //! a `kind::` field. Records whose @@ -26,7 +33,9 @@ const std = @import("std"); const srf = @import("srf"); const snapshot = @import("models/snapshot.zig"); const Date = @import("models/date.zig").Date; +const Candle = @import("models/candle.zig").Candle; const timeline = @import("analytics/timeline.zig"); +const valuation = @import("analytics/valuation.zig"); pub const Error = error{ /// The file didn't open a `#!srfv1` directive or couldn't be @@ -363,6 +372,208 @@ pub fn findNearestSnapshot( return .{ .earlier = earlier, .later = later }; } +/// Result of resolving a requested snapshot date against the history +/// directory. `exact` is true when the requested date had its own +/// snapshot file; false when we auto-snapped to the nearest earlier. +pub const ResolvedSnapshot = struct { + requested: Date, + actual: Date, + exact: bool, +}; + +pub const ResolveSnapshotError = error{ + /// No snapshot file exists at or before the requested date. + NoSnapshotAtOrBefore, +} || std.mem.Allocator.Error || std.fs.Dir.AccessError || std.fs.File.OpenError; + +/// Resolve a requested snapshot date against `hist_dir`: +/// - If `hist_dir/-portfolio.srf` exists, return it as +/// an exact match. +/// - Otherwise, look up the nearest earlier snapshot via +/// `findNearestSnapshot`. Return it as an inexact match. +/// - If nothing exists at or before `requested`, return +/// `error.NoSnapshotAtOrBefore` — the caller decides how to +/// surface that to the user (CLI: stderr; TUI: status bar). +/// +/// Shared between the CLI (`zfin projections --as-of `) and TUI +/// (projections tab date popup) to avoid duplicating the +/// exact-then-fallback resolution logic. +/// +/// Uses `arena` for the two small intermediate strings (filename, +/// full path). Pass a short-lived arena; the returned struct has no +/// borrowed references. +pub fn resolveSnapshotDate( + arena: std.mem.Allocator, + hist_dir: []const u8, + requested: Date, +) ResolveSnapshotError!ResolvedSnapshot { + var date_buf: [10]u8 = undefined; + const date_str = requested.format(&date_buf); + const filename = try std.fmt.allocPrint(arena, "{s}{s}", .{ date_str, snapshot_suffix }); + const full_path = try std.fs.path.join(arena, &.{ hist_dir, filename }); + + std.fs.cwd().access(full_path, .{}) catch |err| switch (err) { + error.FileNotFound => { + const nearest = findNearestSnapshot(hist_dir, requested) catch |e| return e; + if (nearest.earlier) |earlier| { + return .{ .requested = requested, .actual = earlier, .exact = false }; + } + return error.NoSnapshotAtOrBefore; + }, + else => |e| return e, + }; + + return .{ .requested = requested, .actual = requested, .exact = true }; +} + +// ── Pure-domain aggregation ─────────────────────────────────── + +/// Return the prefix of `candles` whose dates are `<= as_of`. +/// +/// When `as_of` is null, returns the full slice unchanged (live mode +/// pass-through). When set, binary-searches for the first index +/// strictly after `as_of` and slices up to it. Zero-length slice +/// when `as_of` precedes all cached candles. +/// +/// Candles are assumed sorted by date ascending. Used to truncate +/// benchmark and per-symbol price history for historical projections — +/// `performance.trailingReturns` uses the last candle's date as the +/// endpoint, so trimming the tail is equivalent to "compute as of +/// that date". +pub fn sliceCandlesAsOf(candles: []const Candle, as_of: ?Date) []const Candle { + const d = as_of orelse return candles; + if (candles.len == 0) return candles; + var lo: usize = 0; + var hi: usize = candles.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + const cd = candles[mid].date; + if (cd.lessThan(d) or cd.eql(d)) { + lo = mid + 1; + } else { + hi = mid; + } + } + return candles[0..lo]; +} + +/// Find the `scope=="liquid"` total in a snapshot. Returns 0.0 if not +/// present (old snapshots from before the liquid/illiquid split — +/// shouldn't happen in practice). +pub fn liquidFromSnapshot(snap: *const snapshot.Snapshot) f64 { + for (snap.totals) |t| { + if (std.mem.eql(u8, t.scope, "liquid")) return t.value; + } + return 0.0; +} + +/// Per-symbol allocations derived from a snapshot's lot rows, plus +/// the totals needed to feed `benchmark.deriveAllocationSplit`. +/// +/// String fields inside `allocations` (`symbol`, `display_symbol`) +/// borrow from the snapshot's backing buffer. Keep the snapshot (and +/// its bytes) alive for the lifetime of these allocations. +pub const SnapshotAllocations = struct { + allocations: []valuation.Allocation, + total_value: f64, + cash_value: f64, + cd_value: f64, + + /// Free the `allocations` slice. `alloc` MUST be the same allocator + /// passed to `aggregateSnapshotAllocations` — the slice is owned + /// by that allocator, not tracked internally. + pub fn deinit(self: *SnapshotAllocations, alloc: std.mem.Allocator) void { + alloc.free(self.allocations); + } +}; + +/// Aggregate a snapshot's `LotRow`s into per-symbol `Allocation`s. +/// +/// Matches the lot aggregation that `valuation.portfolioSummary` does +/// for live portfolios: sum `value` per `symbol`, compute weight +/// against the snapshot's `liquid` total. Non-stock lots contribute +/// to `cash_value` / `cd_value` instead of the allocation list. +/// +/// Security-type strings come from `LotType.label()` in the snapshot +/// writer — "Stock", "Cash", "CD", "Option", "Illiquid". Match is +/// case-sensitive, consistent with `aggregateSnapshotStocks` in +/// `src/compare.zig`. +/// +/// The returned `Allocation`s only populate `symbol`, `display_symbol`, +/// `market_value`, and `weight` — every other field is zero. This is +/// enough for `deriveAllocationSplit` and the per-position trailing +/// returns loop; nothing downstream reads cost basis or shares here. +pub fn aggregateSnapshotAllocations( + alloc: std.mem.Allocator, + snap: *const snapshot.Snapshot, +) !SnapshotAllocations { + const total_value = liquidFromSnapshot(snap); + + var map = std.StringHashMap(f64).init(alloc); + defer map.deinit(); + var cash_value: f64 = 0; + var cd_value: f64 = 0; + + for (snap.lots) |lot| { + if (std.mem.eql(u8, lot.security_type, "Cash")) { + cash_value += lot.value; + continue; + } + if (std.mem.eql(u8, lot.security_type, "CD")) { + cd_value += lot.value; + continue; + } + if (std.mem.eql(u8, lot.security_type, "Illiquid")) { + // Illiquid lots aren't in the liquid total and don't feed + // benchmark projections. Skip. + continue; + } + // Stock + Option: aggregate by `symbol`. For stocks the + // snapshot writer stores `symbol = lot.priceSymbol()` (the + // pricing ticker used for cache lookups, e.g. "BRK-B"), + // distinct from `lot_symbol` which preserves the user's + // original form (e.g. "BRK.B"). For options, `symbol` is the + // contract identifier — options won't have candles in the + // cache, so they're silently dropped from the per-position + // trailing returns loop downstream; they still count toward + // total market value and allocation weight. + const gop = try map.getOrPut(lot.symbol); + if (gop.found_existing) { + gop.value_ptr.* += lot.value; + } else { + gop.value_ptr.* = lot.value; + } + } + + var allocations = try alloc.alloc(valuation.Allocation, map.count()); + errdefer alloc.free(allocations); + + var i: usize = 0; + var it = map.iterator(); + while (it.next()) |e| : (i += 1) { + const mv = e.value_ptr.*; + allocations[i] = .{ + .symbol = e.key_ptr.*, + .display_symbol = e.key_ptr.*, + .shares = 0, + .avg_cost = 0, + .current_price = 0, + .market_value = mv, + .cost_basis = 0, + .weight = if (total_value > 0) mv / total_value else 0, + .unrealized_gain_loss = 0, + .unrealized_return = 0.0, + }; + } + + return .{ + .allocations = allocations, + .total_value = total_value, + .cash_value = cash_value, + .cd_value = cd_value, + }; +} + // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; @@ -767,3 +978,384 @@ test "loadSnapshotAt: happy path loads and parses" { try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), loaded.snap.meta.as_of_date.days); try testing.expectEqual(@as(usize, 3), loaded.snap.totals.len); } + +// ── Aggregation tests ───────────────────────────────────────── + +test "liquidFromSnapshot: finds liquid scope" { + const totals = [_]snapshot.TotalRow{ + .{ .scope = "net_worth", .value = 1100 }, + .{ .scope = "liquid", .value = 1000 }, + .{ .scope = "illiquid", .value = 100 }, + }; + const snap = snapshot.Snapshot{ + .meta = .{ + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2024, 3, 15), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = @constCast(&totals), + .tax_types = &.{}, + .accounts = &.{}, + .lots = &.{}, + }; + try testing.expectEqual(@as(f64, 1000), liquidFromSnapshot(&snap)); +} + +test "liquidFromSnapshot: returns 0 when no liquid scope" { + const totals = [_]snapshot.TotalRow{ + .{ .scope = "net_worth", .value = 1100 }, + }; + const snap = snapshot.Snapshot{ + .meta = .{ + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2024, 3, 15), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = @constCast(&totals), + .tax_types = &.{}, + .accounts = &.{}, + .lots = &.{}, + }; + try testing.expectEqual(@as(f64, 0.0), liquidFromSnapshot(&snap)); +} + +test "aggregateSnapshotAllocations: stocks grouped, cash and CD separated" { + var lots = [_]snapshot.LotRow{ + .{ + .kind = "lot", + .symbol = "AAPL", + .lot_symbol = "AAPL", + .account = "Brokerage", + .security_type = "Stock", + .shares = 100, + .open_price = 120, + .cost_basis = 12_000, + .value = 15_000, + }, + .{ + .kind = "lot", + .symbol = "AAPL", + .lot_symbol = "AAPL", + .account = "Roth", + .security_type = "Stock", + .shares = 50, + .open_price = 150, + .cost_basis = 7_500, + .value = 7_500, + }, + .{ + .kind = "lot", + .symbol = "CASH", + .lot_symbol = "CASH", + .account = "Brokerage", + .security_type = "Cash", + .shares = 10_000, + .open_price = 1, + .cost_basis = 10_000, + .value = 10_000, + }, + .{ + .kind = "lot", + .symbol = "CD-1Y", + .lot_symbol = "CD-1Y", + .account = "Roth", + .security_type = "CD", + .shares = 50_000, + .open_price = 1, + .cost_basis = 50_000, + .value = 50_000, + }, + // Illiquid lots get skipped entirely — they aren't in the + // liquid total and don't affect benchmark projections. + .{ + .kind = "lot", + .symbol = "House", + .lot_symbol = "House", + .account = "Joint", + .security_type = "Illiquid", + .shares = 1, + .open_price = 500_000, + .cost_basis = 500_000, + .value = 500_000, + }, + }; + var totals = [_]snapshot.TotalRow{ + .{ .kind = "total", .scope = "liquid", .value = 82_500 }, + .{ .kind = "total", .scope = "net_worth", .value = 582_500 }, + }; + const snap: snapshot.Snapshot = .{ + .meta = .{ + .kind = "meta", + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2026, 4, 2), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = &totals, + .tax_types = &.{}, + .accounts = &.{}, + .lots = &lots, + }; + + var sa = try aggregateSnapshotAllocations(testing.allocator, &snap); + defer sa.deinit(testing.allocator); + + // One aggregated AAPL row (two lots merged), no illiquid row. + try testing.expectEqual(@as(usize, 1), sa.allocations.len); + try testing.expectEqualStrings("AAPL", sa.allocations[0].symbol); + try testing.expectApproxEqAbs(@as(f64, 22_500), sa.allocations[0].market_value, 0.01); + // weight = 22,500 / 82,500 ≈ 0.2727 + try testing.expectApproxEqAbs(@as(f64, 22_500.0 / 82_500.0), sa.allocations[0].weight, 0.0001); + + try testing.expectApproxEqAbs(@as(f64, 82_500), sa.total_value, 0.01); + try testing.expectApproxEqAbs(@as(f64, 10_000), sa.cash_value, 0.01); + try testing.expectApproxEqAbs(@as(f64, 50_000), sa.cd_value, 0.01); +} + +test "aggregateSnapshotAllocations: no liquid total defaults to zero weights" { + // If the snapshot somehow lacks a `liquid` row, the function + // should still succeed — weights just come out as 0. + var lots = [_]snapshot.LotRow{ + .{ + .kind = "lot", + .symbol = "AAPL", + .lot_symbol = "AAPL", + .account = "A", + .security_type = "Stock", + .shares = 1, + .open_price = 150, + .cost_basis = 150, + .value = 150, + }, + }; + const snap: snapshot.Snapshot = .{ + .meta = .{ + .kind = "meta", + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2026, 4, 2), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = &.{}, + .tax_types = &.{}, + .accounts = &.{}, + .lots = &lots, + }; + + var sa = try aggregateSnapshotAllocations(testing.allocator, &snap); + defer sa.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 1), sa.allocations.len); + try testing.expectEqual(@as(f64, 0), sa.allocations[0].weight); + try testing.expectEqual(@as(f64, 0), sa.total_value); +} + +test "aggregateSnapshotAllocations: aggregates by `symbol` (pricing), not `lot_symbol`" { + // Snapshot writer stores `symbol = priceSymbol()` (e.g. "BRK-B") + // and `lot_symbol = lot.symbol` (user's form, e.g. "BRK.B"). The + // candle cache is keyed by the pricing symbol, so aggregation + // must use `symbol` to match downstream `getCachedCandles` lookups. + // + // This test constructs two lots with the same `symbol` (pricing) + // but different `lot_symbol` values — they should collapse into a + // single allocation. + var lots = [_]snapshot.LotRow{ + .{ + .kind = "lot", + .symbol = "BRK-B", + .lot_symbol = "BRK.B", + .account = "Brokerage", + .security_type = "Stock", + .shares = 10, + .open_price = 400, + .cost_basis = 4_000, + .value = 4_500, + }, + .{ + .kind = "lot", + .symbol = "BRK-B", + .lot_symbol = "BRK-B", + .account = "Roth", + .security_type = "Stock", + .shares = 5, + .open_price = 430, + .cost_basis = 2_150, + .value = 2_250, + }, + }; + var totals = [_]snapshot.TotalRow{ + .{ .kind = "total", .scope = "liquid", .value = 6_750 }, + }; + const snap: snapshot.Snapshot = .{ + .meta = .{ + .kind = "meta", + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2026, 4, 2), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = &totals, + .tax_types = &.{}, + .accounts = &.{}, + .lots = &lots, + }; + + var sa = try aggregateSnapshotAllocations(testing.allocator, &snap); + defer sa.deinit(testing.allocator); + + // Single entry — two lots merged by pricing symbol "BRK-B". + try testing.expectEqual(@as(usize, 1), sa.allocations.len); + try testing.expectEqualStrings("BRK-B", sa.allocations[0].symbol); + try testing.expectApproxEqAbs(@as(f64, 6_750), sa.allocations[0].market_value, 0.01); +} + +// ── sliceCandlesAsOf tests ──────────────────────────────────── + +fn makeTestCandle(y: i16, m: u8, d: u8, close: f64) Candle { + return .{ + .date = Date.fromYmd(y, m, d), + .open = close, + .high = close, + .low = close, + .close = close, + .adj_close = close, + .volume = 0, + }; +} + +test "sliceCandlesAsOf: null as_of returns everything" { + const candles = [_]Candle{ + makeTestCandle(2024, 1, 1, 100), + makeTestCandle(2024, 1, 2, 101), + }; + const sliced = sliceCandlesAsOf(&candles, null); + try testing.expectEqual(@as(usize, 2), sliced.len); +} + +test "sliceCandlesAsOf: empty input" { + const candles = [_]Candle{}; + const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 1)); + try testing.expectEqual(@as(usize, 0), sliced.len); +} + +test "sliceCandlesAsOf: empty input with null as_of" { + const candles = [_]Candle{}; + const sliced = sliceCandlesAsOf(&candles, null); + try testing.expectEqual(@as(usize, 0), sliced.len); +} + +test "sliceCandlesAsOf: exact date match included" { + const candles = [_]Candle{ + makeTestCandle(2024, 1, 1, 100), + makeTestCandle(2024, 1, 2, 101), + makeTestCandle(2024, 1, 3, 102), + makeTestCandle(2024, 1, 4, 103), + }; + const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 2)); + try testing.expectEqual(@as(usize, 2), sliced.len); + try testing.expectApproxEqAbs(@as(f64, 101), sliced[sliced.len - 1].close, 0.001); +} + +test "sliceCandlesAsOf: no exact match snaps to earlier" { + const candles = [_]Candle{ + makeTestCandle(2024, 1, 1, 100), + makeTestCandle(2024, 1, 3, 102), // gap — no candle on the 2nd + makeTestCandle(2024, 1, 4, 103), + }; + // Asking for Jan 2 returns everything through Jan 1 (nothing at/after Jan 2). + const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 2)); + try testing.expectEqual(@as(usize, 1), sliced.len); + try testing.expectApproxEqAbs(@as(f64, 100), sliced[0].close, 0.001); +} + +test "sliceCandlesAsOf: as_of before all candles returns empty" { + const candles = [_]Candle{ + makeTestCandle(2024, 1, 1, 100), + makeTestCandle(2024, 1, 2, 101), + }; + const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2023, 12, 31)); + try testing.expectEqual(@as(usize, 0), sliced.len); +} + +test "sliceCandlesAsOf: as_of after all candles returns everything" { + const candles = [_]Candle{ + makeTestCandle(2024, 1, 1, 100), + makeTestCandle(2024, 1, 2, 101), + }; + const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2026, 1, 1)); + try testing.expectEqual(@as(usize, 2), sliced.len); +} + +// ── resolveSnapshotDate tests ───────────────────────────────── + +test "resolveSnapshotDate: exact match returns exact=true" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(.{ .sub_path = "2024-03-15-portfolio.srf", .data = "" }); + + const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(hist_dir); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const resolved = try resolveSnapshotDate(arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15)); + try testing.expect(resolved.exact); + try testing.expectEqual(Date.fromYmd(2024, 3, 15).days, resolved.actual.days); + try testing.expectEqual(Date.fromYmd(2024, 3, 15).days, resolved.requested.days); +} + +test "resolveSnapshotDate: no exact match snaps to nearest earlier" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(.{ .sub_path = "2024-03-10-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2024-03-20-portfolio.srf", .data = "" }); + + const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(hist_dir); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const resolved = try resolveSnapshotDate(arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15)); + try testing.expect(!resolved.exact); + try testing.expectEqual(Date.fromYmd(2024, 3, 10).days, resolved.actual.days); + try testing.expectEqual(Date.fromYmd(2024, 3, 15).days, resolved.requested.days); +} + +test "resolveSnapshotDate: no earlier snapshot returns NoSnapshotAtOrBefore" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + // Only a later snapshot — can't satisfy a request for an earlier date. + try tmp.dir.writeFile(.{ .sub_path = "2024-04-01-portfolio.srf", .data = "" }); + + const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(hist_dir); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const result = resolveSnapshotDate(arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15)); + try testing.expectError(error.NoSnapshotAtOrBefore, result); +} + +test "resolveSnapshotDate: empty history dir returns NoSnapshotAtOrBefore" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(hist_dir); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const result = resolveSnapshotDate(arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15)); + try testing.expectError(error.NoSnapshotAtOrBefore, result); +} diff --git a/src/main.zig b/src/main.zig index 1577ba4..d4fc60b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -18,13 +18,13 @@ const usage = \\ etf Show ETF profile (holdings, sectors, expense ratio) \\ portfolio Load and analyze the portfolio \\ analysis Show portfolio analysis - \\ contributions Show money added since last commit (git-based diff) + \\ contributions [opts] Show money added since last commit (git-based diff) \\ snapshot [opts] Write a daily portfolio snapshot to history/ \\ compare [] Compare portfolio against snapshot (one date = vs today) \\ enrich Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ lookup Look up CUSIP to ticker via OpenFIGI \\ audit [opts] Reconcile portfolio against brokerage export - \\ projections Retirement projections and benchmark comparison + \\ projections [opts] Retirement projections and benchmark comparison \\ cache stats Show cache statistics \\ cache clear Clear all cached data \\ version [-v] Show zfin version and build info @@ -71,8 +71,21 @@ const usage = \\ Contributions additionally requires the portfolio file to be tracked \\ in a git repo; `git` must be on PATH. \\ + \\Contributions command options: + \\ --since Compare against the portfolio at-or-before DATE + \\ (accepts YYYY-MM-DD or relative like 1M, 3Q, 1Y). + \\ Without --until, the "after" side is HEAD (or + \\ working copy when dirty). Default: HEAD~1..HEAD. + \\ --until Upper bound. Pair with --since to diff two + \\ commits within a date window. + \\ \\Projections command options: \\ --no-events Exclude life events from simulation (baseline view) + \\ --as-of Compute against a historical snapshot instead of + \\ the live portfolio. Accepts YYYY-MM-DD, relative + \\ shortcuts (1W, 1M, 3M, 1Q, 1Y, 3Y, 5Y), or 'live'. + \\ Auto-snaps to nearest-earlier snapshot if the + \\ exact date has no snapshot file. \\ \\Environment Variables: \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) @@ -335,13 +348,13 @@ fn runCli() !u8 { try cli.stderrPrint("Error: 'divs' requires a symbol argument\n"); return 1; } - try commands.divs.run(allocator, &svc, cmd_args[0], color, out); + try commands.divs.run(&svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "splits")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'splits' requires a symbol argument\n"); return 1; } - try commands.splits.run(allocator, &svc, cmd_args[0], color, out); + try commands.splits.run(&svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "options")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'options' requires a symbol argument\n"); @@ -356,19 +369,19 @@ fn runCli() !u8 { ntm = std.fmt.parseInt(usize, cmd_args[ai], 10) catch 8; } } - try commands.options.run(allocator, &svc, cmd_args[0], ntm, color, out); + try commands.options.run(&svc, cmd_args[0], ntm, color, out); } else if (std.mem.eql(u8, command, "earnings")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n"); return 1; } - try commands.earnings.run(allocator, &svc, cmd_args[0], color, out); + try commands.earnings.run(&svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "etf")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'etf' requires a symbol argument\n"); return 1; } - try commands.etf.run(allocator, &svc, cmd_args[0], color, out); + try commands.etf.run(&svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "portfolio")) { // Parse --refresh flag; reject any other token (including old // positional FILE, which is now a global -p). @@ -419,9 +432,35 @@ fn runCli() !u8 { try commands.analysis.run(allocator, &svc, pf.path, color, out); } else if (std.mem.eql(u8, command, "projections")) { var events_enabled = true; - for (cmd_args) |a| { + var as_of: ?zfin.Date = null; + var i: usize = 0; + while (i < cmd_args.len) : (i += 1) { + const a = cmd_args[i]; if (std.mem.eql(u8, a, "--no-events")) { events_enabled = false; + } else if (std.mem.eql(u8, a, "--as-of")) { + if (i + 1 >= cmd_args.len) { + try cli.stderrPrint("Error: --as-of requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n"); + return 1; + } + const value = cmd_args[i + 1]; + const today = cli.fmt.todayDate(); + const parsed = cli.parseAsOfDate(value, today) catch |err| { + var buf: [256]u8 = undefined; + const msg = cli.fmtAsOfParseError(&buf, value, err); + try cli.stderrPrint(msg); + try cli.stderrPrint("\n"); + return 1; + }; + // null = live (leave as_of null); non-null = resolved date. + if (parsed) |d| { + if (d.days > today.days) { + try cli.stderrPrint("Error: --as-of date is in the future.\n"); + return 1; + } + as_of = d; + } + i += 1; // consume the value } else { try reportUnexpectedArg("projections", a); return 1; @@ -429,15 +468,56 @@ fn runCli() !u8 { } const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); - try commands.projections.run(allocator, &svc, pf.path, events_enabled, color, out); + try commands.projections.run(allocator, &svc, pf.path, events_enabled, as_of, color, out); } else if (std.mem.eql(u8, command, "contributions")) { - for (cmd_args) |a| { - try reportUnexpectedArg("contributions", a); + var since: ?zfin.Date = null; + var until: ?zfin.Date = null; + var i: usize = 0; + while (i < cmd_args.len) : (i += 1) { + const a = cmd_args[i]; + if (std.mem.eql(u8, a, "--since") or std.mem.eql(u8, a, "--until")) { + if (i + 1 >= cmd_args.len) { + try cli.stderrPrint("Error: "); + try cli.stderrPrint(a); + try cli.stderrPrint(" requires a value (YYYY-MM-DD or N[WMQY]).\n"); + return 1; + } + const value = cmd_args[i + 1]; + const today = cli.fmt.todayDate(); + const parsed = cli.parseAsOfDate(value, today) catch |err| { + var buf: [256]u8 = undefined; + const msg = cli.fmtAsOfParseError(&buf, value, err); + try cli.stderrPrint(msg); + try cli.stderrPrint("\n"); + return 1; + }; + // `parsed == null` means the user typed "live" or an + // empty string — meaningless for --since/--until, which + // require concrete dates. + const resolved = parsed orelse { + try cli.stderrPrint("Error: "); + try cli.stderrPrint(a); + try cli.stderrPrint(" does not accept 'live'. Use an explicit date or relative offset.\n"); + return 1; + }; + if (std.mem.eql(u8, a, "--since")) { + since = resolved; + } else { + until = resolved; + } + i += 1; // consume the value + } else { + try reportUnexpectedArg("contributions", a); + return 1; + } + } + if (since != null and until != null and since.?.days > until.?.days) { + try cli.stderrPrint("Error: --since must be on or before --until.\n"); return 1; } const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); - try commands.contributions.run(allocator, &svc, pf.path, color, out); + try commands.contributions.run(allocator, &svc, pf.path, since, until, color, out); } else if (std.mem.eql(u8, command, "snapshot")) { const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); diff --git a/src/models/etf_profile.zig b/src/models/etf_profile.zig index fa45959..e1c42b2 100644 --- a/src/models/etf_profile.zig +++ b/src/models/etf_profile.zig @@ -50,6 +50,30 @@ pub const EtfProfile = struct { self.sectors != null or self.total_holdings != null; } + + /// Free any owned fields on this profile. + /// + /// Matches the inline cleanup previously inlined in + /// `src/commands/etf.zig`. Only `holdings` and `sectors` are + /// freed here — the top-level optional strings (`name`, + /// `asset_class`, `category`, `description`) are borrowed from + /// the cache store's shared buffer in the provider-fetched path + /// and don't need freeing. If that changes (e.g., a provider + /// starts allocating each field separately), extend this + /// function accordingly. + pub fn deinit(self: EtfProfile, allocator: std.mem.Allocator) void { + if (self.holdings) |h| { + for (h) |holding| { + if (holding.symbol) |s| allocator.free(s); + allocator.free(holding.name); + } + allocator.free(h); + } + if (self.sectors) |s| { + for (s) |sec| allocator.free(sec.name); + allocator.free(s); + } + } }; const std = @import("std"); diff --git a/src/models/option.zig b/src/models/option.zig index 75a29c8..9231bcc 100644 --- a/src/models/option.zig +++ b/src/models/option.zig @@ -32,4 +32,21 @@ pub const OptionsChain = struct { expiration: Date, calls: []const OptionContract, puts: []const OptionContract, + + /// Free any owned fields on this chain. Mirrors the pattern in + /// `Dividend.deinit` — callers who own a single chain can release + /// it directly; callers with a slice use `freeSlice` below. + pub fn deinit(self: OptionsChain, allocator: std.mem.Allocator) void { + allocator.free(self.underlying_symbol); + allocator.free(self.calls); + allocator.free(self.puts); + } + + /// Free a slice of chains, calling `deinit` on each element first. + pub fn freeSlice(allocator: std.mem.Allocator, chains: []const OptionsChain) void { + for (chains) |c| c.deinit(allocator); + allocator.free(chains); + } }; + +const std = @import("std"); diff --git a/src/service.zig b/src/service.zig index 36c585e..d3ef632 100644 --- a/src/service.zig +++ b/src/service.zig @@ -62,11 +62,41 @@ pub const Source = enum { }; /// Generic result type for all fetch operations: data payload + provenance metadata. +/// +/// `data` is owned by `allocator` — call `result.deinit()` to release +/// it (both the outer slice/struct and any nested owned fields). This +/// replaces the earlier "caller frees with whatever allocator they +/// happen to have" pattern, which was error-prone when the caller's +/// allocator (e.g. an arena) differed from the service's allocator. pub fn FetchResult(comptime T: type) type { return struct { data: cache.Store.DataFor(T), source: Source, timestamp: i64, + /// Allocator that owns `data`. Populated by the service on + /// every return path; callers use it via `deinit` rather than + /// touching it directly. + allocator: std.mem.Allocator, + + /// Free `data` and any nested owned fields. + /// + /// Dispatches at comptime: + /// - If `T` has a `freeSlice` helper (Dividend, OptionsChain), + /// call it — handles element deinit plus the outer slice. + /// - Else if `data` is a slice (Candle, Split, EarningsEvent), + /// do a simple slice free. + /// - Else if `T` has a `deinit` method (EtfProfile), call it + /// on the struct itself. + pub fn deinit(self: @This()) void { + const DT = @TypeOf(self.data); + if (@hasDecl(T, "freeSlice")) { + T.freeSlice(self.allocator, self.data); + } else if (@typeInfo(DT) == .pointer) { + self.allocator.free(self.data); + } else if (@hasDecl(T, "deinit")) { + self.data.deinit(self.allocator); + } + } }; } @@ -250,14 +280,14 @@ pub const DataService = struct { if (s.read(T, symbol, postProcess, .fresh_only)) |cached| { log.debug("{s}: {s} fresh in local cache", .{ symbol, @tagName(data_type) }); - return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator() }; } // Try server sync before hitting providers if (self.syncFromServer(symbol, data_type)) { if (s.read(T, symbol, postProcess, .fresh_only)) |cached| { log.debug("{s}: {s} synced from server and fresh", .{ symbol, @tagName(data_type) }); - return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator() }; } log.debug("{s}: {s} synced from server but stale, falling through to provider", .{ symbol, @tagName(data_type) }); } @@ -271,14 +301,14 @@ pub const DataService = struct { return DataError.FetchFailed; }; s.write(T, symbol, retried, data_type.ttl()); - return .{ .data = retried, .source = .fetched, .timestamp = std.time.timestamp() }; + return .{ .data = retried, .source = .fetched, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } s.writeNegative(symbol, data_type); return DataError.FetchFailed; }; s.write(T, symbol, fetched, data_type.ttl()); - return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; + return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } /// Dispatch a fetch to the correct provider based on model type. @@ -434,14 +464,14 @@ pub const DataService = struct { // Fresh — deserialize candles and return log.debug("{s}: candles fresh in local cache", .{symbol}); if (s.read(Candle, symbol, null, .any)) |r| - return .{ .data = r.data, .source = .cached, .timestamp = mr.created }; + return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator() }; } else { // Stale — try server sync before incremental fetch if (self.syncCandlesFromServer(symbol)) { if (s.isCandleMetaFresh(symbol)) { log.debug("{s}: candles synced from server and fresh", .{symbol}); if (s.read(Candle, symbol, null, .any)) |r| - return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; + return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } log.debug("{s}: candles synced from server but stale, falling through to incremental fetch", .{symbol}); } @@ -453,7 +483,7 @@ pub const DataService = struct { if (!fetch_from.lessThan(today)) { s.updateCandleMeta(symbol, m.last_close, m.last_date, m.provider, m.fail_count); if (s.read(Candle, symbol, null, .any)) |r| - return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; + return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } else { // Incremental fetch from day after last cached candle const result = self.fetchCandlesFromProviders(symbol, fetch_from, today, m.provider) catch |err| { @@ -467,13 +497,13 @@ pub const DataService = struct { if (new_fail_count >= 3) { log.warn("{s}: degraded after {d} consecutive failures, returning stale data", .{ symbol, new_fail_count }); if (s.read(Candle, symbol, null, .any)) |r| - return .{ .data = r.data, .source = .cached, .timestamp = mr.created }; + return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator() }; } return DataError.TransientError; } // Non-transient failure — return stale data if available if (s.read(Candle, symbol, null, .any)) |r| - return .{ .data = r.data, .source = .cached, .timestamp = mr.created }; + return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator() }; return DataError.FetchFailed; }; const new_candles = result.candles; @@ -483,15 +513,15 @@ pub const DataService = struct { self.allocator().free(new_candles); s.updateCandleMeta(symbol, m.last_close, m.last_date, result.provider, 0); if (s.read(Candle, symbol, null, .any)) |r| - return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; + return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } else { // Append new candles to existing file + update meta, reset fail_count s.appendCandles(symbol, new_candles, result.provider, 0); if (s.read(Candle, symbol, null, .any)) |r| { self.allocator().free(new_candles); - return .{ .data = r.data, .source = .fetched, .timestamp = std.time.timestamp() }; + return .{ .data = r.data, .source = .fetched, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } - return .{ .data = new_candles, .source = .fetched, .timestamp = std.time.timestamp() }; + return .{ .data = new_candles, .source = .fetched, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } } } @@ -502,7 +532,7 @@ pub const DataService = struct { if (s.isCandleMetaFresh(symbol)) { log.debug("{s}: candles synced from server and fresh (no prior cache)", .{symbol}); if (s.read(Candle, symbol, null, .any)) |r| - return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; + return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } log.debug("{s}: candles synced from server but stale, falling through to full fetch", .{symbol}); } @@ -528,7 +558,7 @@ pub const DataService = struct { s.cacheCandles(symbol, result.candles, result.provider, 0); // reset fail_count on success } - return .{ .data = result.candles, .source = .fetched, .timestamp = std.time.timestamp() }; + return .{ .data = result.candles, .source = .fetched, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } /// Fetch dividend history for a symbol. @@ -553,7 +583,7 @@ pub const DataService = struct { pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) { // Mutual funds (5-letter tickers ending in X) don't have quarterly earnings. if (isMutualFund(symbol)) { - return .{ .data = &.{}, .source = .cached, .timestamp = std.time.timestamp() }; + return .{ .data = &.{}, .source = .cached, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } var s = self.store(); @@ -568,7 +598,7 @@ pub const DataService = struct { if (!needs_refresh) { log.debug("{s}: earnings fresh in local cache", .{symbol}); - return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator() }; } // Stale: free cached events and re-fetch below self.allocator().free(cached.data); @@ -578,7 +608,7 @@ pub const DataService = struct { if (self.syncFromServer(symbol, .earnings)) { if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { log.debug("{s}: earnings synced from server and fresh", .{symbol}); - return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator() }; } log.debug("{s}: earnings synced from server but stale, falling through to provider", .{symbol}); } @@ -599,7 +629,7 @@ pub const DataService = struct { s.write(EarningsEvent, symbol, fetched, cache.Ttl.earnings); - return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; + return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } /// Fetch ETF profile for a symbol. @@ -608,7 +638,7 @@ pub const DataService = struct { var s = self.store(); if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached| - return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator() }; var av = try self.getProvider(AlphaVantage); const fetched = av.fetchEtfProfile(self.allocator(), symbol) catch |err| blk: { @@ -624,7 +654,7 @@ pub const DataService = struct { s.write(EtfProfile, symbol, fetched, cache.Ttl.etf_profile); - return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; + return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp(), .allocator = self.allocator() }; } /// Fetch a real-time quote for a symbol. @@ -745,11 +775,14 @@ pub const DataService = struct { /// Read candles from cache only (no network fetch). Used by TUI for display. /// Returns null if no cached data exists or if the entry is a negative cache (fetch_failed). - pub fn getCachedCandles(self: *DataService, symbol: []const u8) ?[]Candle { + /// + /// Returns a `FetchResult(Candle)` so the caller can `result.deinit()` + /// without needing to know the service's internal allocator. + pub fn getCachedCandles(self: *DataService, symbol: []const u8) ?FetchResult(Candle) { var s = self.store(); if (s.isNegative(symbol, .candles_daily)) return null; const result = s.read(Candle, symbol, null, .any) orelse return null; - return result.data; + return .{ .data = result.data, .source = .cached, .timestamp = result.timestamp, .allocator = self.allocator() }; } /// Read dividends from cache only (no network fetch). @@ -1548,6 +1581,7 @@ test "FetchResult type construction" { .data = &.{}, .source = .cached, .timestamp = 0, + .allocator = std.testing.allocator, }; try std.testing.expect(candle_result.source == .cached); @@ -1555,6 +1589,7 @@ test "FetchResult type construction" { .data = &.{}, .source = .fetched, .timestamp = 12345, + .allocator = std.testing.allocator, }; try std.testing.expect(div_result.source == .fetched); try std.testing.expectEqual(@as(i64, 12345), div_result.timestamp); diff --git a/src/tui.zig b/src/tui.zig index b459c7c..01fa9c5 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -15,7 +15,7 @@ const earnings_tab = @import("tui/earnings_tab.zig"); const analysis_tab = @import("tui/analysis_tab.zig"); const history_tab = @import("tui/history_tab.zig"); const projections_tab = @import("tui/projections_tab.zig"); -const history_io = @import("history.zig"); +const history = @import("history.zig"); const timeline = @import("analytics/timeline.zig"); const compare_core = @import("compare.zig"); const compare_view = @import("views/compare.zig"); @@ -106,6 +106,10 @@ pub const InputMode = enum { help, account_picker, account_search, + /// Mini popup on the projections tab for entering an as-of date. + /// Same input scaffolding as `symbol_input` (shared `input_buf`), + /// committed via `parseAsOfDate`. + date_input, }; pub const StyledLine = struct { @@ -408,7 +412,7 @@ pub const App = struct { // History tab state history_loaded: bool = false, history_disabled: bool = false, // true when no portfolio path (history requires it) - history_timeline: ?history_io.LoadedTimeline = null, + history_timeline: ?history.LoadedTimeline = null, // Cursor for the recent-snapshots table. 0 = newest row (live // pseudo-row if available, otherwise newest snapshot). history_cursor: usize = 0, @@ -447,6 +451,17 @@ pub const App = struct { projections_events_enabled: bool = true, projections_value_min: f64 = 0, projections_value_max: f64 = 0, + /// When non-null, the projections tab renders against a historical + /// snapshot instead of the live portfolio. Set via the `d` popup + /// (parsed by `cli.parseAsOfDate`) and auto-snapped to the nearest + /// earlier available snapshot. Cleared by `D` or by committing + /// an empty / "live" input. + projections_as_of: ?zfin.Date = null, + /// When auto-snap kicked in, `projections_as_of` is the resolved + /// snapshot date but `projections_as_of_requested` remembers what + /// the user actually typed — surfaced in the tab header as a muted + /// "(requested X; snapped to Y, N days earlier)" note. + projections_as_of_requested: ?zfin.Date = null, // Default to `.liquid` — that's the metric most worth watching // day-to-day. Illiquid barely changes, net_worth is dominated by // liquid anyway, so "show me liquid" is the headline view. @@ -480,6 +495,9 @@ pub const App = struct { if (self.mode == .symbol_input) { return self.handleInputKey(ctx, key); } + if (self.mode == .date_input) { + return self.handleDateInputKey(ctx, key); + } if (self.mode == .account_picker) { return self.handleAccountPickerKey(ctx, key); } @@ -669,48 +687,156 @@ pub const App = struct { } } - /// Handles keypresses in symbol_input mode (activated by `/`). - /// Mini text input for typing a ticker symbol (e.g. AAPL, BRK.B, ^GSPC). - fn handleInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { - // Cancel input, return to normal mode + /// Outcome of a single keypress in an input-mode buffer (symbol + /// input, date input, etc.). Returned by `handleInputBuffer` so + /// the per-mode caller only needs to wire up the `committed` + /// branch with its own semantics; the shared scaffolding (Esc to + /// cancel, Backspace/Ctrl+U to edit, printable to append) is + /// handled once. + const InputBufferResult = enum { + /// Esc pressed. Caller should exit input mode; the shared + /// helper has already reset `input_len` and set mode back to + /// `.normal`. + cancelled, + /// Enter pressed. Caller reads `self.input_buf[0..self.input_len]` + /// to commit, then resets mode + length. + committed, + /// Character appended / removed / cleared. Caller should just + /// redraw; no further action. + edited, + /// Key didn't match any input-buffer semantic (e.g., a + /// function key). Caller may ignore or layer on its own + /// handling; the helper didn't consume the event. + ignored, + }; + + /// Shared input-buffer state machine. Handles Esc (cancel), + /// Backspace/Ctrl+U (edit), and printable-ASCII append. Returns + /// the outcome so the caller can wire up Enter and Esc/edit + /// side-effects on its own. + /// + /// Behavior on `cancelled`: resets `self.mode = .normal` and + /// `self.input_len = 0`. Caller typically sets a status message + /// and calls `ctx.consumeAndRedraw()`. + /// + /// Does not touch state on `committed` — caller owns the commit + /// (reading the buffer, dispatching to downstream, resetting + /// mode/length when done). + fn handleInputBuffer(self: *App, key: vaxis.Key) InputBufferResult { if (key.codepoint == vaxis.Key.escape) { self.mode = .normal; self.input_len = 0; - self.setStatus("Cancelled"); - return ctx.consumeAndRedraw(); + return .cancelled; } - // Commit: uppercase the input, set as active symbol, switch to quote tab if (key.codepoint == vaxis.Key.enter) { - if (self.input_len > 0) { - for (self.input_buf[0..self.input_len]) |*ch| ch.* = std.ascii.toUpper(ch.*); - @memcpy(self.symbol_buf[0..self.input_len], self.input_buf[0..self.input_len]); - self.symbol = self.symbol_buf[0..self.input_len]; - self.symbol_owned = true; - self.has_explicit_symbol = true; - self.resetSymbolData(); - self.active_tab = .quote; - self.loadTabData(); - ctx.queueRefresh() catch {}; - } - self.mode = .normal; - self.input_len = 0; - return ctx.consumeAndRedraw(); + return .committed; } - // Delete last character if (key.codepoint == vaxis.Key.backspace) { if (self.input_len > 0) self.input_len -= 1; - return ctx.consumeAndRedraw(); + return .edited; } // Ctrl+U: clear entire input (readline convention) if (key.matches('u', .{ .ctrl = true })) { self.input_len = 0; - return ctx.consumeAndRedraw(); + return .edited; } - // Accept printable ASCII (letters, digits, dots, hyphens, carets for tickers) + // Accept printable ASCII (letters, digits, common punctuation). if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and self.input_len < self.input_buf.len) { self.input_buf[self.input_len] = @intCast(key.codepoint); self.input_len += 1; - return ctx.consumeAndRedraw(); + return .edited; + } + return .ignored; + } + + /// Handles keypresses in symbol_input mode (activated by `/`). + /// Mini text input for typing a ticker symbol (e.g. AAPL, BRK.B, ^GSPC). + fn handleInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { + switch (self.handleInputBuffer(key)) { + .cancelled => { + self.setStatus("Cancelled"); + return ctx.consumeAndRedraw(); + }, + .edited => return ctx.consumeAndRedraw(), + .ignored => {}, + .committed => { + // Commit: uppercase the input, set as active symbol, switch to quote tab + if (self.input_len > 0) { + for (self.input_buf[0..self.input_len]) |*ch| ch.* = std.ascii.toUpper(ch.*); + @memcpy(self.symbol_buf[0..self.input_len], self.input_buf[0..self.input_len]); + self.symbol = self.symbol_buf[0..self.input_len]; + self.symbol_owned = true; + self.has_explicit_symbol = true; + self.resetSymbolData(); + self.active_tab = .quote; + self.loadTabData(); + ctx.queueRefresh() catch {}; + } + self.mode = .normal; + self.input_len = 0; + return ctx.consumeAndRedraw(); + }, + } + } + + /// Handles keypresses in date_input mode (activated by `d` on the + /// projections tab). + /// + /// Accepts the same input as the CLI `--as-of` flag — `YYYY-MM-DD`, + /// relative shortcuts (`1W`, `1M`, `3M`, `1Q`, `1Y`, `3Y`, `5Y`), + /// or `live` / empty for live state. Commit via Enter, cancel via + /// Esc. + fn handleDateInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { + switch (self.handleInputBuffer(key)) { + .cancelled => { + self.setStatus("Cancelled"); + return ctx.consumeAndRedraw(); + }, + .edited => return ctx.consumeAndRedraw(), + .ignored => {}, + .committed => { + const input = self.input_buf[0..self.input_len]; + const today = fmt.todayDate(); + const parsed = cli.parseAsOfDate(input, today) catch |err| { + var buf: [256]u8 = undefined; + const msg = cli.fmtAsOfParseError(&buf, input, err); + self.setStatus(msg); + self.mode = .normal; + self.input_len = 0; + return ctx.consumeAndRedraw(); + }; + + if (parsed) |d| { + // Guard against future dates. + if (d.days > today.days) { + self.setStatus("As-of date is in the future"); + self.mode = .normal; + self.input_len = 0; + return ctx.consumeAndRedraw(); + } + self.projections_as_of = d; + self.projections_as_of_requested = null; + var date_buf: [10]u8 = undefined; + var status_buf: [64]u8 = undefined; + const date_str = d.format(&date_buf); + const msg = std.fmt.bufPrint(&status_buf, "As-of: {s}", .{date_str}) catch "As-of set"; + self.setStatus(msg); + } else { + // `null` parse result = live. + self.projections_as_of = null; + self.projections_as_of_requested = null; + self.setStatus("As-of cleared — showing live"); + } + + projections_tab.freeLoaded(self); + self.projections_loaded = false; + projections_tab.loadData(self); + + self.mode = .normal; + self.input_len = 0; + ctx.queueRefresh() catch {}; + return ctx.consumeAndRedraw(); + }, } } @@ -952,7 +1078,8 @@ pub const App = struct { if (history_tab.handleCompareKey(self, ctx, key)) return; } - // Escape: clear account filter on portfolio tab, no-op otherwise + // Escape: clear account filter on portfolio tab, clear as-of + // on projections tab, no-op otherwise. if (key.codepoint == vaxis.Key.escape) { if (self.active_tab == .portfolio and self.account_filter != null) { self.setAccountFilter(null); @@ -962,6 +1089,15 @@ pub const App = struct { self.setStatus("Filter cleared: showing all accounts"); return ctx.consumeAndRedraw(); } + if (self.active_tab == .projections and self.projections_as_of != null) { + self.projections_as_of = null; + self.projections_as_of_requested = null; + projections_tab.freeLoaded(self); + self.projections_loaded = false; + projections_tab.loadData(self); + self.setStatus("As-of cleared — showing live"); + return ctx.consumeAndRedraw(); + } return; } @@ -1194,6 +1330,18 @@ pub const App = struct { return ctx.consumeAndRedraw(); } }, + .projections_as_of_input => { + // Only meaningful on the projections tab. Other tabs + // let the same key flow to their own handlers (none + // currently bind plain 'd'). + if (self.active_tab == .projections) { + self.mode = .date_input; + self.input_len = 0; + // No setStatus — drawStatusBar replaces the whole + // line with the prompt + hint when mode is .date_input. + return ctx.consumeAndRedraw(); + } + }, // History-tab compare actions are normally intercepted in // `handleCompareKey` before `matchAction` runs (because the // default 's'/'c'/space/escape key bindings belong to other @@ -1642,12 +1790,7 @@ pub const App = struct { pub fn freeOptions(self: *App) void { if (self.options_data) |chains| { - for (chains) |chain| { - self.allocator.free(chain.calls); - self.allocator.free(chain.puts); - self.allocator.free(chain.underlying_symbol); - } - self.allocator.free(chains); + zfin.OptionsChain.freeSlice(self.allocator, chains); } self.options_data = null; } @@ -1878,36 +2021,45 @@ pub const App = struct { } } + /// Render a prompt + live input buffer + blinking cursor + right- + /// aligned hint into the status-bar cell buffer. Shared between + /// `.symbol_input` and `.date_input` modes — only the prompt and + /// hint text differ. + fn renderInputPrompt(self: *App, buf: []vaxis.Cell, width: u16, prompt: []const u8, hint: []const u8) void { + const t = self.theme; + const prompt_style = t.inputStyle(); + @memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style }); + + for (0..@min(prompt.len, width)) |i| { + buf[i] = .{ .char = .{ .grapheme = glyph(prompt[i]) }, .style = prompt_style }; + } + const input = self.input_buf[0..self.input_len]; + for (0..@min(input.len, @as(usize, width) -| prompt.len)) |i| { + buf[prompt.len + i] = .{ .char = .{ .grapheme = glyph(input[i]) }, .style = prompt_style }; + } + const cursor_pos = prompt.len + self.input_len; + if (cursor_pos < width) { + var cursor_style = prompt_style; + cursor_style.blink = true; + buf[cursor_pos] = .{ .char = .{ .grapheme = "_" }, .style = cursor_style }; + } + if (width > hint.len + cursor_pos + 2) { + const hint_start = width - hint.len; + const hint_style = t.inputHintStyle(); + for (0..hint.len) |i| { + buf[hint_start + i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = hint_style }; + } + } + } + fn drawStatusBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface { const t = self.theme; const buf = try ctx.arena.alloc(vaxis.Cell, width); if (self.mode == .symbol_input) { - const prompt_style = t.inputStyle(); - @memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style }); - - const prompt = "Symbol: "; - for (0..@min(prompt.len, width)) |i| { - buf[i] = .{ .char = .{ .grapheme = glyph(prompt[i]) }, .style = prompt_style }; - } - const input = self.input_buf[0..self.input_len]; - for (0..@min(input.len, @as(usize, width) -| prompt.len)) |i| { - buf[prompt.len + i] = .{ .char = .{ .grapheme = glyph(input[i]) }, .style = prompt_style }; - } - const cursor_pos = prompt.len + self.input_len; - if (cursor_pos < width) { - var cursor_style = prompt_style; - cursor_style.blink = true; - buf[cursor_pos] = .{ .char = .{ .grapheme = "_" }, .style = cursor_style }; - } - const hint = " Enter=confirm Esc=cancel "; - if (width > hint.len + cursor_pos + 2) { - const hint_start = width - hint.len; - const hint_style = t.inputHintStyle(); - for (0..hint.len) |i| { - buf[hint_start + i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = hint_style }; - } - } + self.renderInputPrompt(buf, width, "Symbol: ", " Enter=confirm Esc=cancel "); + } else if (self.mode == .date_input) { + self.renderInputPrompt(buf, width, "As-of: ", " YYYY-MM-DD | 1M | live Enter=confirm "); } else if (self.mode == .account_picker) { const prompt_style = t.inputStyle(); @memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style }); diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 5f6f253..0633fda 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -40,7 +40,7 @@ const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); -const history_io = @import("../history.zig"); +const history = @import("../history.zig"); const timeline = @import("../analytics/timeline.zig"); const view = @import("../views/history.zig"); const compare_core = @import("../compare.zig"); @@ -59,7 +59,7 @@ pub fn loadData(app: *App) void { return; }; - app.history_timeline = history_io.loadTimeline(app.allocator, portfolio_path) catch { + app.history_timeline = history.loadTimeline(app.allocator, portfolio_path) catch { app.setStatus("Failed to read history/ directory"); return; }; @@ -254,7 +254,7 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void { app.setStatus("No portfolio loaded — can't build compare"); return error.PortfolioLoadFailed; }; - const hist_dir = try history_io.deriveHistoryDir(app.allocator, portfolio_path); + const hist_dir = try history.deriveHistoryDir(app.allocator, portfolio_path); defer app.allocator.free(hist_dir); // SAFETY: assigned in both branches of the `if (older.is_live)` diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 5913fb9..9f1301b 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -58,6 +58,15 @@ pub const Action = enum { account_filter, toggle_chart, toggle_events, + /// Projections tab: open the as-of date input popup. Default: 'd'. + /// Accepts YYYY-MM-DD, N[WMQY] shortcuts (1W, 1M, 3M, 1Q, 1Y), or 'live'. + /// Empty input + Enter returns to live. See `parseAsOfDate` in + /// `src/commands/common.zig`. + /// + /// To return to live without opening the popup, press Esc on the + /// projections tab while an as-of date is active. That path is + /// intercepted directly in `tui.zig` — no separate keybind action. + projections_as_of_input, }; pub const KeyCombo = struct { @@ -157,6 +166,10 @@ const default_bindings = [_]Binding{ .{ .action = .account_filter, .key = .{ .codepoint = 'a' } }, .{ .action = .toggle_chart, .key = .{ .codepoint = 'v' } }, .{ .action = .toggle_events, .key = .{ .codepoint = 'e' } }, + // Projections-tab date-picker popup. `d` opens the popup; to + // clear an active as-of date, press Esc while on the projections + // tab (intercepted in `tui.zig` before `matchAction`). + .{ .action = .projections_as_of_input, .key = .{ .codepoint = 'd' } }, }; pub fn defaults() KeyMap { diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 9695056..01b5133 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -111,7 +111,7 @@ pub fn loadPortfolioData(app: *App) void { if (app.watchlist) |wl| { for (wl) |sym| { const result = app.svc.getCandles(sym) catch continue; - defer app.allocator.free(result.data); + defer result.deinit(); if (result.data.len > 0) { wp.put(sym, result.data[result.data.len - 1].close) catch {}; } @@ -121,7 +121,7 @@ pub fn loadPortfolioData(app: *App) void { if (lot.security_type == .watch) { const sym = lot.priceSymbol(); const result = app.svc.getCandles(sym) catch continue; - defer app.allocator.free(result.data); + defer result.deinit(); if (result.data.len > 0) { wp.put(sym, result.data[result.data.len - 1].close) catch {}; } @@ -1253,10 +1253,10 @@ pub fn reloadPortfolioFile(app: *App) void { // Cache only — no network const candles_slice = app.svc.getCachedCandles(sym); if (candles_slice) |cs| { - defer app.allocator.free(cs); - if (cs.len > 0) { - prices.put(sym, cs[cs.len - 1].close) catch {}; - const d = cs[cs.len - 1].date; + defer cs.deinit(); + if (cs.data.len > 0) { + prices.put(sym, cs.data[cs.data.len - 1].close) catch {}; + const d = cs.data[cs.data.len - 1].date; if (latest_date == null or d.days > latest_date.?.days) latest_date = d; } } else { diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index d5c5af2..1500cbd 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -10,6 +10,14 @@ //! Consumes `src/analytics/projections.zig` (simulation engine), //! `src/analytics/benchmark.zig` (weighted returns), and //! `src/views/projections.zig` (view model). +//! +//! ## As-of mode +//! +//! When `app.projections_as_of` is non-null, the tab renders against a +//! historical snapshot instead of the live portfolio, using +//! `view.loadProjectionContextAsOf`. The user toggles this via the `d` +//! keybind (date popup) or `D` (return to live). Auto-snaps to the +//! nearest earlier snapshot when the exact date isn't available. const std = @import("std"); const vaxis = @import("vaxis"); @@ -24,6 +32,7 @@ const benchmark = @import("../analytics/benchmark.zig"); const performance = @import("../analytics/performance.zig"); const valuation = @import("../analytics/valuation.zig"); const view = @import("../views/projections.zig"); +const history = @import("../history.zig"); const App = tui.App; const StyledLine = tui.StyledLine; @@ -38,17 +47,79 @@ pub fn loadData(app: *App) void { return; }; + const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0; + const portfolio_dir = portfolio_path[0..dir_end]; + + // As-of mode — load historical snapshot + ctx. This path is + // independent of `app.portfolio_summary` / `app.portfolio` because + // the snapshot's own totals and lot composition are the source of + // truth for the projection. + // + // On any failure (no snapshot at/before requested date, unreadable + // file, compute error) we clear the as-of state, leave a status + // message explaining why, and fall through to the live path so + // the tab still shows something rather than going blank. + as_of: { + const requested_date = app.projections_as_of orelse break :as_of; + + const actual_date = resolveSnapshotDate(app, portfolio_path, requested_date) orelse { + // `setStatus` already called by resolveSnapshotDate. + app.projections_as_of = null; + app.projections_as_of_requested = null; + break :as_of; + }; + app.projections_as_of = actual_date; + // Preserve requested for the header note; clear if it matches actual. + if (actual_date.eql(requested_date)) { + app.projections_as_of_requested = null; + } + + const hist_dir = history.deriveHistoryDir(app.allocator, portfolio_path) catch { + app.setStatus("Failed to derive history dir — showing live"); + app.projections_as_of = null; + app.projections_as_of_requested = null; + break :as_of; + }; + defer app.allocator.free(hist_dir); + + var loaded = history.loadSnapshotAt(app.allocator, hist_dir, actual_date) catch { + app.setStatus("Failed to load snapshot — showing live"); + app.projections_as_of = null; + app.projections_as_of_requested = null; + break :as_of; + }; + defer loaded.deinit(app.allocator); + + const ctx = view.loadProjectionContextAsOf( + app.allocator, + portfolio_dir, + &loaded.snap, + actual_date, + app.svc, + app.projections_events_enabled, + ) catch { + app.setStatus("Failed to compute as-of projections — showing live"); + app.projections_as_of = null; + app.projections_as_of_requested = null; + break :as_of; + }; + + app.projections_ctx = ctx; + return; + } + + // Live path. Reached either because no as-of was requested OR the + // as-of branch above bailed and fell through after clearing state. const summary = app.portfolio_summary orelse { app.setStatus("No portfolio summary — visit Portfolio tab first"); return; }; const portfolio = app.portfolio orelse return; - const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0; const ctx = view.loadProjectionContext( app.allocator, - portfolio_path[0..dir_end], + portfolio_dir, summary.allocations, summary.total_value, portfolio.totalCash(), @@ -63,6 +134,50 @@ pub fn loadData(app: *App) void { app.projections_ctx = ctx; } +/// Resolve the user's requested as-of date against the portfolio's +/// history directory. Returns the actual date to load (exact match or +/// nearest-earlier snapshot), or null with a status-bar message if +/// no usable snapshot exists. +/// +/// Thin adapter over `history.resolveSnapshotDate` — the shared pure +/// resolver owns the exact/snap logic; this wrapper maps its errors +/// to user-visible status-bar messages and handles the arena. +fn resolveSnapshotDate(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?zfin.Date { + var arena_state = std.heap.ArenaAllocator.init(app.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const hist_dir = history.deriveHistoryDir(arena, portfolio_path) catch { + app.setStatus("Failed to derive history dir"); + return null; + }; + + const resolved = history.resolveSnapshotDate(arena, hist_dir, requested) catch |err| switch (err) { + error.NoSnapshotAtOrBefore => { + var date_buf: [10]u8 = undefined; + var status_buf: [128]u8 = undefined; + const date_str = requested.format(&date_buf); + const msg = std.fmt.bufPrint(&status_buf, "No snapshot at or before {s}", .{date_str}) catch "No snapshot at or before requested date"; + app.setStatus(msg); + return null; + }, + error.OutOfMemory => { + app.setStatus("Out of memory resolving snapshot"); + return null; + }, + else => { + app.setStatus("Error accessing snapshot"); + return null; + }, + }; + + if (!resolved.exact) { + // Remember the original request for the muted header note. + app.projections_as_of_requested = requested; + } + return resolved.actual; +} + pub fn freeLoaded(app: *App) void { if (app.projections_ctx) |ctx| { app.allocator.free(ctx.data.withdrawals); @@ -509,6 +624,32 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine const config = ctx.config; const stock_pct = ctx.stock_pct; + // As-of indicator — only shown when the tab is displaying a + // historical snapshot. Muted header note so it doesn't compete + // with the main content. If the user asked for a date that had no + // exact snapshot, a second muted line explains the auto-snap. + if (app.projections_as_of) |actual| { + var actual_buf: [10]u8 = undefined; + const actual_str = actual.format(&actual_buf); + const header = try std.fmt.allocPrint(arena, " As-of: {s} (snapshot)", .{actual_str}); + try lines.append(arena, .{ .text = header, .style = th.mutedStyle() }); + + if (app.projections_as_of_requested) |requested| { + if (!requested.eql(actual)) { + var req_buf: [10]u8 = undefined; + const req_str = requested.format(&req_buf); + const diff = requested.days - actual.days; + const note = try std.fmt.allocPrint( + arena, + " (requested {s}; snapped back {d} day{s})", + .{ req_str, diff, fmt.dayPlural(diff) }, + ); + try lines.append(arena, .{ .text = note, .style = th.mutedStyle() }); + } + } + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + } + // Header try lines.append(arena, .{ .text = " Benchmark Comparison", diff --git a/src/views/compare.zig b/src/views/compare.zig index 3c33db3..cb64b56 100644 --- a/src/views/compare.zig +++ b/src/views/compare.zig @@ -90,6 +90,27 @@ pub const TotalsRow = struct { style: StyleIntent, }; +/// Optional attribution breakdown of the liquid delta into +/// contributions (money in) vs investment gains (market movement). +/// +/// Populated in both single-date ("vs current") and two-date modes +/// when the portfolio is git-tracked and both endpoints resolve to +/// commits via `git.resolveCommitRange`. Silently null when the git +/// lookup fails (missing repo, untracked file, no commits in range); +/// the compare view falls back to just the totals + per-symbol table. +/// +/// Math: `delta = contributions + gains`, so `gains = delta - contributions`. +/// Signs are preserved: negative contributions (net withdrawal) and +/// negative gains (market loss) both appear. +pub const Attribution = struct { + /// Sum of new-money contributions plus DRIP reinvestments (what + /// `zfin contributions` reports as "money in"). + contributions: f64, + /// `TotalsRow.delta - contributions`. The residual — what the + /// market actually did. + gains: f64, +}; + /// Complete compare view. `symbols` is caller-owned; call `deinit()`. pub const CompareView = struct { then_date: Date, @@ -116,6 +137,10 @@ pub const CompareView = struct { /// Symbols present in "then" but not "now" — position closed /// between the two dates. Never rendered as rows; shown as a count. removed_count: usize, + /// Optional contributions-vs-gains breakdown of `liquid.delta`. + /// Populated by the CLI from `computeAttribution` when a git repo + /// is available; always null in unit-tested / TUI flows. + attribution: ?Attribution = null, pub fn deinit(self: *CompareView, allocator: std.mem.Allocator) void { allocator.free(self.symbols); @@ -575,10 +600,9 @@ pub fn nowLabel(cv: CompareView, buf: *[10]u8) []const u8 { return cv.now_date.format(buf); } -/// English pluralization for the "(N day[s])" header suffix. -pub fn dayPlural(n: i32) []const u8 { - return if (n == 1) "" else "s"; -} +/// Re-export of `format.dayPlural` so callers keep a single import. +/// The canonical implementation lives in `src/format.zig`. +pub const dayPlural = fmt.dayPlural; test "buildSymbolRowCells: wires through the right formatters" { var p_then: [24]u8 = undefined; diff --git a/src/views/projections.zig b/src/views/projections.zig index b9b6438..9a77473 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -9,6 +9,9 @@ const benchmark = @import("../analytics/benchmark.zig"); const projections = @import("../analytics/projections.zig"); const valuation = @import("../analytics/valuation.zig"); const zfin = @import("../root.zig"); +const snapshot_model = @import("../models/snapshot.zig"); +const history = @import("../history.zig"); +const Date = @import("../models/date.zig").Date; pub const StyleIntent = fmt.StyleIntent; @@ -223,6 +226,104 @@ pub fn loadProjectionContext( cd_value: f64, svc: *zfin.DataService, events_enabled: bool, +) !ProjectionContext { + return buildContextFromParts( + alloc, + portfolio_dir, + allocations, + total_value, + cash_value, + cd_value, + svc, + events_enabled, + null, + ); +} + +// ── As-of (historical) projection context ────────────────────── +// +// Retrospective projections. Builds a ProjectionContext as if it were +// a past `as_of` date, using a stored snapshot for portfolio +// composition and truncating benchmark candle history at `as_of` so +// trailing returns reflect what was knowable at that moment. +// +// The snapshot→allocations aggregation (`SnapshotAllocations` and +// `aggregateSnapshotAllocations`) lives in `src/history.zig` next to +// the other snapshot-domain helpers. This module orchestrates the +// full projection pipeline through `buildContextFromParts`. +// +// Known limitation: `projections.srf` and `metadata.srf` are still +// loaded from the current working copy (git-tracked). This means +// retirement ages, target allocation, confidence levels, and symbol +// classifications are all "as of now", not as of the requested date. +// Documented edge case; see TODO.md. + +/// Build a complete `ProjectionContext` as of a historical `as_of_date`. +/// +/// Mirrors `loadProjectionContext` but sources portfolio composition +/// from a snapshot instead of the live portfolio file: +/// - Allocations derived from snapshot's lot rows +/// - Total value / cash / CD taken from snapshot +/// - Benchmark candles truncated to <= as_of_date +/// - Per-symbol trailing returns truncated to <= as_of_date +/// - Life events resolved against ages-as-of-as_of via +/// `UserConfig.currentAgesAsOf` +/// +/// Known as-of limitations (documented): +/// - `metadata.srf` classifications are current, not historical. +/// Symbols reclassified since the snapshot use the new class. +/// - `projections.srf` config (retirement ages, horizons, target +/// allocation) is current, not historical. +/// - Per-symbol candles older than ~10yr may be missing from cache, +/// causing null trailing returns for older windows. +/// +/// Caller owns the returned context. `snap` must outlive the context +/// (allocation symbol strings borrow from the snapshot's backing +/// buffer — see `history.aggregateSnapshotAllocations`). +pub fn loadProjectionContextAsOf( + alloc: std.mem.Allocator, + portfolio_dir: []const u8, + snap: *const snapshot_model.Snapshot, + as_of_date: Date, + svc: *zfin.DataService, + events_enabled: bool, +) !ProjectionContext { + var snap_allocs = try history.aggregateSnapshotAllocations(alloc, snap); + defer snap_allocs.deinit(alloc); + + return buildContextFromParts( + alloc, + portfolio_dir, + snap_allocs.allocations, + snap_allocs.total_value, + snap_allocs.cash_value, + snap_allocs.cd_value, + svc, + events_enabled, + as_of_date, + ); +} + +/// Shared core: build a `ProjectionContext` from pre-computed +/// allocations and totals. Both `loadProjectionContext` (live) and +/// `loadProjectionContextAsOf` (historical) delegate here. +/// +/// `as_of` gates two behaviors: +/// - `null` → live mode. Benchmark + per-symbol candles used as-is; +/// events resolved against current ages (`resolveEvents()`). +/// - `|d|` → historical mode. Benchmark + per-symbol candles sliced +/// to `<= d`; events resolved against ages-as-of-d +/// (`resolveEventsWithAges(currentAgesAsOf(d))`). +fn buildContextFromParts( + alloc: std.mem.Allocator, + portfolio_dir: []const u8, + allocations: []const valuation.Allocation, + total_value: f64, + cash_value: f64, + cd_value: f64, + svc: *zfin.DataService, + events_enabled: bool, + as_of: ?Date, ) !ProjectionContext { // Load projections.srf const proj_path = try std.fmt.allocPrint(alloc, "{s}projections.srf", .{portfolio_dir}); @@ -252,25 +353,37 @@ pub fn loadProjectionContext( cd_value, ); - // Fetch benchmark candles (checks cache first) + // Fetch benchmark candles (checks cache first). In historical + // mode we slice to `<= as_of` — `performance.trailingReturns` + // anchors on the last candle's date, so trimming the tail gives + // returns "as of" that date for free. const spy_result = svc.getCandles("SPY") catch null; - const spy_candles = if (spy_result) |r| r.data else &.{}; - defer if (spy_result) |r| alloc.free(r.data); + defer if (spy_result) |r| r.deinit(); + const spy_candles = history.sliceCandlesAsOf( + if (spy_result) |r| r.data else &.{}, + as_of, + ); + const agg_result = svc.getCandles("AGG") catch null; - const agg_candles = if (agg_result) |r| r.data else &.{}; - defer if (agg_result) |r| alloc.free(r.data); + defer if (agg_result) |r| r.deinit(); + const agg_candles = history.sliceCandlesAsOf( + if (agg_result) |r| r.data else &.{}, + as_of, + ); const spy_trailing = performance.trailingReturns(spy_candles); const agg_trailing = performance.trailingReturns(agg_candles); const spy_week = performance.weekReturn(spy_candles); const agg_week = performance.weekReturn(agg_candles); - // Build per-position trailing returns + // Build per-position trailing returns from cached candles, each + // optionally truncated to the as-of date. var pos_returns: std.ArrayListUnmanaged(benchmark.PositionReturn) = .empty; defer pos_returns.deinit(alloc); for (allocations) |a| { - const candles = svc.getCachedCandles(a.symbol) orelse continue; - defer alloc.free(candles); + const candles_res = svc.getCachedCandles(a.symbol) orelse continue; + defer candles_res.deinit(); + const candles = history.sliceCandlesAsOf(candles_res.data, as_of); if (candles.len > 0) { try pos_returns.append(alloc, .{ .symbol = a.symbol, @@ -290,11 +403,29 @@ pub fn loadProjectionContext( agg_week, ); - // Resolve events to simulation years - const resolved = config.resolveEvents(); - const resolved_events = resolved[0..config.event_count]; + // Event resolution differs by mode: + // - Live: current ages (resolveEvents uses config.currentAges()). + // - As-of: ages-as-of the requested date, so an event at age 67 + // that's 17 years from today but 28 years from 2016 resolves + // correctly against the historical reference frame. + const resolved_events = if (as_of) |d| blk: { + const ages = config.currentAgesAsOf(d); + const resolved = config.resolveEventsWithAges(&ages); + break :blk resolved[0..config.event_count]; + } else blk: { + const resolved = config.resolveEvents(); + break :blk resolved[0..config.event_count]; + }; - return buildProjectionContext(alloc, config, comparison, split.stock_pct, split.bond_pct, total_value, resolved_events); + return buildProjectionContext( + alloc, + config, + comparison, + split.stock_pct, + split.bond_pct, + total_value, + resolved_events, + ); } // ── Table row builders (shared by CLI and TUI) ─────────────────