periodic review: projections --as-of, contributions --since, comparison and tui projections date picker

This commit is contained in:
Emil Lerch 2026-05-01 15:59:17 -07:00
parent 0aabdfb4f1
commit d15939a9ad
Signed by: lobo
GPG key ID: A7B62D657EF764F8
31 changed files with 2964 additions and 444 deletions

View file

@ -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

View file

@ -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 -- <args> # 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.

17
TODO.md
View file

@ -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 <DATE>` 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

View file

@ -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);
}
}
}

View file

@ -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');
}

View file

@ -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 <date>" 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 {

View file

@ -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 <DATE>`: commit-at-or-before(DATE) vs HEAD (or working copy if dirty)
//! - `--since <D1> --until <D2>`: commit-at-or-before(D1) vs commit-at-or-before(D2)
//! - `--until <DATE>` alone: rejected; window is ambiguous
//!
//! The `--since` / `--until` flags use `commitAtOrBeforeDate` in
//! `src/git.zig`, which runs `git log --until=<DATE> -1 -- portfolio.srf`
//! to pick the most recent commit at or before the requested date.
//! Relative forms (1M, 3Q, 1Y) are also accepted parsed by
//! `cli.parseAsOfDate` and resolved to an absolute date before being
//! passed in.
//!
//! 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 <DATE>` 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=<DATE>`,
// 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"));
}

View file

@ -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");

View file

@ -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

View file

@ -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");

View file

@ -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) {

View file

@ -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");

View file

@ -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);
}

View file

@ -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

View file

@ -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 };
}

View file

@ -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");

View file

@ -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();

View file

@ -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();

View file

@ -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 <DATE>` / `--until <DATE>` 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);
}

View file

@ -1,7 +1,9 @@
//! History IO read `history/<date>-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::<meta|total|tax_type|account|lot>` 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/<requested>-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 <DATE>`) 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);
}

View file

@ -18,13 +18,13 @@ const usage =
\\ etf <SYMBOL> 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 <DATE> [<DATE>] Compare portfolio against snapshot (one date = vs today)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> 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 <DATE> 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 <DATE> 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 <DATE|N[WMQY]> 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);

View file

@ -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");

View file

@ -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");

View file

@ -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);

View file

@ -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 });

View file

@ -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)`

View file

@ -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 {

View file

@ -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 {

View file

@ -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",

View file

@ -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;

View file

@ -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 snapshotallocations 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)