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 0bf44581e1
commit f7c6224a1b
31 changed files with 2964 additions and 444 deletions

View file

@ -29,7 +29,7 @@ repos:
- id: test - id: test
name: Run zig build test name: Run zig build test
entry: zig entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=56"] args: ["build", "coverage", "-Dcoverage-threshold=60"]
language: system language: system
types: [file] types: [file]
pass_filenames: false 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 test # run all tests (single binary, discovers all tests via refAllDeclsRecursive)
zig build run -- <args> # build and run CLI zig build run -- <args> # build and run CLI
zig build docs # generate library documentation 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`): **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 2. Add the tab variant to `tui.Tab` enum
3. Wire rendering in `tui.zig`'s draw and event handling 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 ## 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. - **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 value and re-run `computePercentileBands` with that starting point, then plot
actual values from later snapshots as a line overlaid on the bands. 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 ## Analysis account/asset-class total mismatch
The "By Account" and "By Tax Type" sections in the analysis command sum to slightly 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| { for (positions) |pos| {
if (pos.shares <= 0) continue; if (pos.shares <= 0) continue;
if (svc.getCachedCandles(pos.symbol)) |cs| { if (svc.getCachedCandles(pos.symbol)) |cs| {
defer allocator.free(cs); defer cs.deinit();
if (cs.len > 0) { if (cs.data.len > 0) {
try prices.put(pos.symbol, cs[cs.len - 1].close); 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 // Stderr helpers
pub fn stderrPrint(msg: []const u8) !void { pub fn stderrPrint(msg: []const u8) !void {
@ -357,7 +415,11 @@ pub fn buildPortfolioData(
} }
for (syms) |sym| { for (syms) |sym| {
if (svc.getCachedCandles(sym)) |cs| { 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 // Watchlist loading
/// Load a watchlist SRF file containing symbol records. /// 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 // Should use positive (green) color for zero
try std.testing.expect(std.mem.indexOf(u8, out, "127") != null); 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 cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
const Date = zfin.Date; const Date = zfin.Date;
const history_io = @import("../history.zig"); const history = @import("../history.zig");
const compare_core = @import("../compare.zig"); const compare_core = @import("../compare.zig");
const view = @import("../views/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{ pub const Error = error{
UnexpectedArg, UnexpectedArg,
@ -137,7 +139,7 @@ pub fn run(
} }
// Resolve history dir // 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); defer allocator.free(hist_dir);
// Load both sides // Load both sides
@ -159,18 +161,20 @@ pub fn run(
var now_live = try LiveSide.load(allocator, svc, portfolio_path, color); var now_live = try LiveSide.load(allocator, svc, portfolio_path, color);
defer now_live.deinit(allocator); defer now_live.deinit(allocator);
try renderFromParts( // Attribution spans then_date HEAD (or working copy if dirty).
out, // `computeAttribution` with until=null uses exactly that semantics.
color, const attribution = contributions_cmd.computeAttribution(allocator, svc, portfolio_path, then_date, null, color);
then_date,
now_date, try renderFromParts(out, color, allocator, .{
true, .then_date = then_date,
then_side.liquid, .now_date = now_date,
now_live.liquid, .now_is_live = true,
&then_side.map, .then_liquid = then_side.liquid,
&now_live.map, .now_liquid = now_live.liquid,
allocator, .then_map = &then_side.map,
); .now_map = &now_live.map,
.attribution = attribution,
});
} else { } else {
var now_side = compare_core.loadSnapshotSide(allocator, hist_dir, now_date) catch |err| switch (err) { var now_side = compare_core.loadSnapshotSide(allocator, hist_dir, now_date) catch |err| switch (err) {
error.FileNotFound => { error.FileNotFound => {
@ -181,27 +185,30 @@ pub fn run(
}; };
defer now_side.deinit(allocator); defer now_side.deinit(allocator);
try renderFromParts( // Attribution spans the explicit then_date now_date window.
out, const attribution = contributions_cmd.computeAttribution(allocator, svc, portfolio_path, then_date, now_date, color);
color,
then_date, try renderFromParts(out, color, allocator, .{
now_date, .then_date = then_date,
false, .now_date = now_date,
then_side.liquid, .now_is_live = false,
now_side.liquid, .then_liquid = then_side.liquid,
&then_side.map, .now_liquid = now_side.liquid,
&now_side.map, .then_map = &then_side.map,
allocator, .now_map = &now_side.map,
); .attribution = attribution,
});
} }
} }
/// Build the view from two holdings maps + totals, then render. /// Inputs needed to build + render a `CompareView`. Bundled into a
/// Factored out so both the live and snapshot "now" paths share a /// struct so `renderFromParts` stays one line of call-site noise
/// single call site. /// instead of an 11-positional-arg parade.
fn renderFromParts( ///
out: *std.Io.Writer, /// `then_map` / `now_map` are borrowed pointers; the caller keeps the
color: bool, /// underlying maps alive through the render call. `attribution` is
/// optional and folded into the view only when set.
const RenderArgs = struct {
then_date: Date, then_date: Date,
now_date: Date, now_date: Date,
now_is_live: bool, now_is_live: bool,
@ -209,20 +216,45 @@ fn renderFromParts(
now_liquid: f64, now_liquid: f64,
then_map: *const view.HoldingMap, then_map: *const view.HoldingMap,
now_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, allocator: std.mem.Allocator,
args: RenderArgs,
) !void { ) !void {
var cv = try view.buildCompareView( var cv = try view.buildCompareView(
allocator, allocator,
then_date, args.then_date,
now_date, args.now_date,
now_is_live, args.now_is_live,
then_liquid, args.then_liquid,
now_liquid, args.now_liquid,
then_map, args.then_map,
now_map, args.now_map,
); );
defer cv.deinit(allocator); 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); try renderCompare(out, color, cv);
} }
@ -306,13 +338,13 @@ const LiveSide = struct {
/// Print a "no snapshot for <date>" message plus the nearest earlier /// Print a "no snapshot for <date>" message plus the nearest earlier
/// and later available dates to stderr. Wraps the pure /// and later available dates to stderr. Wraps the pure
/// `history_io.findNearestSnapshot` with CLI-specific output. /// `history.findNearestSnapshot` with CLI-specific output.
fn suggestNearest( fn suggestNearest(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
hist_dir: []const u8, hist_dir: []const u8,
target: Date, target: Date,
) !void { ) !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("Error scanning history directory: ");
try cli.stderrPrint(@errorName(err)); try cli.stderrPrint(@errorName(err));
try cli.stderrPrint("\n"); 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. // Totals line two-color: muted "then → now", intent-colored delta/pct.
try renderTotalsLine(out, color, cv.liquid); 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", .{}); try out.print("\n", .{});
// Per-symbol table // Per-symbol table
if (cv.held_count == 0) { if (cv.held_count == 0) {
try cli.setFg(out, color, cli.CLR_MUTED); try cli.printFg(out, color, cli.CLR_MUTED, "No symbols held throughout this period.\n", .{});
try out.print("No symbols held throughout this period.\n", .{});
try cli.reset(out, color);
} else { } else {
try cli.setFg(out, color, cli.CLR_MUTED); try cli.printFg(out, color, cli.CLR_MUTED, "Per-symbol price change ({d} held throughout)\n", .{cv.held_count});
try out.print("Per-symbol price change ({d} held throughout)\n", .{cv.held_count});
try cli.reset(out, color);
for (cv.symbols) |s| { for (cv.symbols) |s| {
try renderSymbolRow(out, color, s); try renderSymbolRow(out, color, s);
@ -400,13 +436,11 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void {
// Hidden count // Hidden count
if (cv.added_count > 0 or cv.removed_count > 0) { if (cv.added_count > 0 or cv.removed_count > 0) {
try out.print("\n", .{}); try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED); try cli.printFg(out, color, cli.CLR_MUTED, "({d} added, {d} removed since {s} — hidden)\n", .{
try out.print("({d} added, {d} removed since {s} — hidden)\n", .{
cv.added_count, cv.added_count,
cv.removed_count, cv.removed_count,
then_str, 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); const c = view.buildTotalsCells(t, &then_buf, &now_buf, &delta_buf, &pct_buf);
try out.print("Liquid: ", .{}); try out.print("Liquid: ", .{});
// "then → now" in muted color try cli.printFg(out, color, cli.CLR_MUTED, "{s}{s}{s}", .{ c.then, view.arrow, c.now });
try cli.setFg(out, color, cli.CLR_MUTED); try cli.printIntent(out, color, c.style, " {s} {s}\n", .{ c.delta, c.pct });
try out.print("{s}{s}{s}", .{ c.then, view.arrow, c.now }); }
try cli.reset(out, color);
// Delta + pct in intent color /// Render the contributions-vs-gains attribution line directly beneath
try cli.setStyleIntent(out, color, c.style); /// the Liquid totals. Matches the email format:
try out.print(" {s} {s}\n", .{ c.delta, c.pct }); ///
try cli.reset(out, color); /// 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 { 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. // Leading indent + symbol in default color.
try out.print(" " ++ view.symbol_fmt ++ " ", .{c.symbol}); try out.print(" " ++ view.symbol_fmt ++ " ", .{c.symbol});
// "then → now" in muted color. // "then → now" in muted color.
try cli.setFg(out, color, cli.CLR_MUTED); 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 });
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);
// Delta/pct in intent color. // Delta/pct in intent color.
try cli.setStyleIntent(out, color, c.style); try cli.printIntent(out, color, c.style, " " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar });
try out.print(" " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar });
try cli.reset(out, color);
} }
// Tests // Tests
@ -617,6 +672,89 @@ test "renderCompare: negative totals delta" {
try testing.expect(std.mem.indexOf(u8, out, "-10.00%") != null); 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 // run() entry-point validation tests
fn makeTestSvc() zfin.DataService { fn makeTestSvc() zfin.DataService {

View file

@ -1,9 +1,20 @@
//! `zfin contributions` show money added to the portfolio since the //! `zfin contributions` show money added to the portfolio since the
//! last recorded state in git. //! last recorded state in git.
//! //!
//! Compares two snapshots of portfolio.srf: //! Four modes:
//! - dirty working tree: HEAD vs working copy (default case) //! - No flags (default):
//! - clean working tree: HEAD~1 vs HEAD (review last commit) //! - 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: //! Classifies each lot-level change as:
//! - New contribution (new lot, or cash increase for a fresh cash line) //! - 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 // 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( pub fn run(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
svc: *zfin.DataService, svc: *zfin.DataService,
portfolio_path: []const u8, portfolio_path: []const u8,
since: ?Date,
until: ?Date,
color: bool, color: bool,
out: *std.Io.Writer, out: *std.Io.Writer,
) !void { ) !void {
@ -44,101 +65,352 @@ pub fn run(
defer arena_state.deinit(); defer arena_state.deinit();
const arena = arena_state.allocator(); const arena = arena_state.allocator();
// 1. Figure out the git repo and the portfolio's path inside it. // Enforce the "--until without --since is ambiguous" rule at the
const repo = git.findRepo(arena, portfolio_path) catch |err| { // entry point so `resolveEndpoints`/`git.resolveCommitRange` can
switch (err) { // assume the invariant.
error.NotInGitRepo => try cli.stderrPrint("Error: contributions requires portfolio.srf to be in a git repo.\n"), if (since == null and until != null) {
error.GitUnavailable => try cli.stderrPrint("Error: could not run 'git'. Is git installed and on PATH?\n"), try cli.stderrPrint("Error: --until requires --since. Use `contributions --since <DATE>` or both.\n");
else => try cli.stderrPrint("Error locating git repo.\n"),
}
return; 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 = git.pathStatus(arena, repo.root, repo.rel_path) catch {
const status = try git.pathStatus(arena, repo.root, repo.rel_path); if (verbosity == .verbose) cli.stderrPrint("Error: could not determine git status of portfolio.srf.\n") catch {};
return error.PrepareFailed;
};
if (status == .untracked) { if (status == .untracked) {
try cli.stderrPrint("Error: portfolio.srf is not tracked in git. Add and commit it first.\n"); if (verbosity == .verbose) cli.stderrPrint("Error: portfolio.srf is not tracked in git. Add and commit it first.\n") catch {};
return; return error.PrepareFailed;
} }
const dirty = status == .modified; const dirty = status == .modified;
// 3. Pull both snapshots. const endpoints = resolveEndpoints(arena, repo, since, until, dirty, verbosity) catch return error.PrepareFailed;
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 after = if (dirty) // Pull both sides: before is always from git; after is either
std.fs.cwd().readFileAlloc(arena, portfolio_path, 10 * 1024 * 1024) catch { // from git (at some revision) or from the working copy.
try cli.stderrPrint("Error reading working-copy portfolio file.\n"); const before = git.show(arena, repo.root, endpoints.range.before_rev, repo.rel_path) catch |err| {
return; 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 return error.PrepareFailed;
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;
}; };
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 { var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch {
try cli.stderrPrint("Error parsing after-snapshot portfolio.\n"); if (verbosity == .verbose) cli.stderrPrint("Error parsing after-snapshot portfolio.\n") catch {};
return; 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); var prices = std.StringHashMap(f64).init(arena);
// Union of stock symbols from both snapshots.
var sym_set = std.StringHashMap(void).init(arena); var sym_set = std.StringHashMap(void).init(arena);
for (before_pf.lots) |l| { for (before_pf.lots) |l| {
if (l.security_type == .stock and !(l.price != null and l.ticker == null)) { 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| { for (after_pf.lots) |l| {
if (l.security_type == .stock and !(l.price != null and l.ticker == null)) { 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 syms: std.ArrayList([]const u8) = .empty;
var sit = sym_set.keyIterator(); 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) { if (syms.items.len > 0) {
var load_result = cli.loadPortfolioPrices(svc, syms.items, &.{}, false, color); var load_result = cli.loadPortfolioPrices(svc, syms.items, &.{}, false, color);
defer load_result.deinit(); defer load_result.deinit();
var pit = load_result.prices.iterator(); var pit = load_result.prices.iterator();
while (pit.next()) |entry| { 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 const report = computeReport(arena, before_pf.lots, after_pf.lots, &prices, fmt.todayDate()) catch {
// lives in the arena; no explicit deinit needed. if (verbosity == .verbose) cli.stderrPrint("Error computing contributions diff.\n") catch {};
const report = try computeReport(arena, before_pf.lots, after_pf.lots, &prices, fmt.todayDate()); return error.PrepareFailed;
};
try printReport(out, &report, dirty, color); return .{
try out.flush(); .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 // Git discovery / invocation
@ -448,7 +720,7 @@ fn computeReport(
// Output // 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 h_color = cli.CLR_HEADER;
const pos_color = cli.CLR_POSITIVE; const pos_color = cli.CLR_POSITIVE;
const mut_color = cli.CLR_MUTED; 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 out.writeAll("Portfolio contributions report\n");
try cli.reset(out, color); try cli.reset(out, color);
try cli.setFg(out, color, mut_color); try cli.setFg(out, color, mut_color);
if (dirty) { try out.writeAll(" ");
try out.writeAll(" Comparing working copy against HEAD\n\n"); try out.writeAll(label);
} else { try out.writeAll("\n\n");
try out.writeAll(" Working tree clean — comparing HEAD~1 against HEAD\n\n");
}
try cli.reset(out, color); try cli.reset(out, color);
// If nothing changed at all, say so explicitly and return. // 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 { fn printSection(out: *std.Io.Writer, title: []const u8, color: bool, hdr: [3]u8) !void {
try cli.setBold(out, color); try cli.setBold(out, color);
try cli.setFg(out, color, hdr); try cli.printFg(out, color, hdr, "== {s} ==\n", .{title});
try out.writeAll("== ");
try out.writeAll(title);
try out.writeAll(" ==\n");
try cli.reset(out, color);
} }
fn printNone(out: *std.Io.Writer, color: bool, muted: [3]u8) !void { fn printNone(out: *std.Io.Writer, color: bool, muted: [3]u8) !void {
try cli.setFg(out, color, muted); try cli.printFg(out, color, muted, " (none)\n", .{});
try out.writeAll(" (none)\n");
try cli.reset(out, color);
} }
fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void { fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void {
var buf: [32]u8 = undefined; var buf: [32]u8 = undefined;
try cli.setFg(out, color, hdr); try cli.printFg(out, color, hdr, " {s}: {s}\n", .{ label, fmt.fmtMoneyAbs(&buf, v) });
try out.print(" {s}: {s}\n", .{ label, fmt.fmtMoneyAbs(&buf, v) });
try cli.reset(out, color);
} }
fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !void { 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; const acct = if (c.account.len == 0) "(no account)" else c.account;
try out.print(" {s:<14}{s:<24}", .{ c.symbol, acct }); try out.print(" {s:<14}{s:<24}", .{ c.symbol, acct });
if (c.security_type == .cash) { if (c.security_type == .cash) {
try cli.setFg(out, color, pos); try cli.printFg(out, color, pos, " {s}", .{val_str});
try out.print(" {s}", .{val_str});
try cli.reset(out, color);
} else { } else {
try out.print(" {s} shares × {s} = ", .{ share_str, price_str }); try out.print(" {s} shares × {s} = ", .{ share_str, price_str });
try cli.setFg(out, color, pos); try cli.printFg(out, color, pos, "{s}", .{val_str});
try out.print("{s}", .{val_str});
try cli.reset(out, color);
} }
try out.writeAll("\n"); 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| { if (implied_interest) |i| {
var int_buf: [32]u8 = undefined; var int_buf: [32]u8 = undefined;
try cli.setFg(out, color, cli.CLR_POSITIVE); try cli.printFg(out, color, cli.CLR_POSITIVE, " {s:<14}{s:<24} implied interest: {s}\n", .{ "", "", fmt.fmtMoneyAbs(&int_buf, i) });
try out.print(" {s:<14}{s:<24} implied interest: {s}\n", .{ "", "", fmt.fmtMoneyAbs(&int_buf, i) });
try cli.reset(out, color);
} }
} }
@ -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 acct = if (c.account.len == 0) "(no account)" else c.account;
const sign = if (v >= 0) "+" else "-"; const sign = if (v >= 0) "+" else "-";
try out.print(" {s:<14}{s:<24} cash ", .{ c.symbol, acct }); try out.print(" {s:<14}{s:<24} cash ", .{ c.symbol, acct });
try cli.setGainLoss(out, color, v); try cli.printGainLoss(out, color, v, "{s}{s}", .{ sign, fmt.fmtMoneyAbs(&val_buf, @abs(v)) });
try out.print("{s}{s}", .{ sign, fmt.fmtMoneyAbs(&val_buf, @abs(v)) });
try cli.reset(out, color);
// Hint if a CD matured in the same account. // Hint if a CD matured in the same account.
for (report.changes) |o| { for (report.changes) |o| {
if (o.kind == .cd_matured and std.mem.eql(u8, o.account, c.account)) { if (o.kind == .cd_matured and std.mem.eql(u8, o.account, c.account)) {
var face_buf: [32]u8 = undefined; var face_buf: [32]u8 = undefined;
try cli.setFg(out, color, cli.CLR_MUTED); try cli.printFg(out, color, cli.CLR_MUTED, " (may include CD maturity of {s})", .{fmt.fmtMoneyAbs(&face_buf, o.face_value)});
try out.print(" (may include CD maturity of {s})", .{fmt.fmtMoneyAbs(&face_buf, o.face_value)});
try cli.reset(out, color);
break; 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 old_buf: [32]u8 = undefined;
var new_buf: [32]u8 = undefined; var new_buf: [32]u8 = undefined;
const acct = if (c.account.len == 0) "(no account)" else c.account; const acct = if (c.account.len == 0) "(no account)" else c.account;
try cli.setFg(out, color, muted); try cli.printFg(out, color, muted, " {s:<14}{s:<24} price {s} → {s}\n", .{
try out.print(" {s:<14}{s:<24} price {s} → {s}\n", .{
c.symbol, c.symbol,
acct, acct,
fmt.fmtMoneyAbs(&old_buf, c.old_price), fmt.fmtMoneyAbs(&old_buf, c.old_price),
fmt.fmtMoneyAbs(&new_buf, c.new_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 { 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, 195.8), t.rollup, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), t.new_money, 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 cli = @import("common.zig");
const fmt = cli.fmt; 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) { const result = svc.getDividends(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); 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; return;
}, },
}; };
defer zfin.Dividend.freeSlice(allocator, result.data); defer result.deinit();
if (result.source == .cached) try cli.stderrPrint("(using cached dividend data)\n"); 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 cli = @import("common.zig");
const fmt = cli.fmt; 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) { const result = svc.getEarnings(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n"); 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; return;
}, },
}; };
defer allocator.free(result.data); defer result.deinit();
// Sort newest-first the first row is the most recent quarter, which // Sort newest-first the first row is the most recent quarter, which
// is the dominant query. Matches `git log` / `ls -lt` / `last` defaults // 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 cli = @import("common.zig");
const fmt = cli.fmt; 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) { const result = svc.getEtfProfile(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); 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; const profile = result.data;
defer { defer result.deinit();
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);
}
}
if (result.source == .cached) try cli.stderrPrint("(using cached ETF profile)\n"); 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 cli = @import("common.zig");
const atomic = @import("../atomic.zig"); const atomic = @import("../atomic.zig");
const timeline = @import("../analytics/timeline.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 snapshot_model = @import("../models/snapshot.zig");
const view = @import("../views/history.zig"); const view = @import("../views/history.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
@ -114,7 +114,7 @@ pub fn run(
out: *std.Io.Writer, out: *std.Io.Writer,
) !void { ) !void {
if (args.len > 0 and args[0].len > 0 and args[0][0] != '-') { 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; return;
} }
@ -135,7 +135,6 @@ pub fn run(
// Symbol mode (legacy) // Symbol mode (legacy)
fn runSymbol( fn runSymbol(
allocator: std.mem.Allocator,
svc: *zfin.DataService, svc: *zfin.DataService,
symbol: []const u8, symbol: []const u8,
color: bool, color: bool,
@ -151,7 +150,7 @@ fn runSymbol(
return; return;
}, },
}; };
defer allocator.free(result.data); defer result.deinit();
if (result.source == .cached) try cli.stderrPrint("(using cached data)\n"); if (result.source == .cached) try cli.stderrPrint("(using cached data)\n");
@ -201,7 +200,7 @@ fn runPortfolio(
color: bool, color: bool,
out: *std.Io.Writer, out: *std.Io.Writer,
) !void { ) !void {
var tl = try history_io.loadTimeline(allocator, portfolio_path); var tl = try history.loadTimeline(allocator, portfolio_path);
defer tl.deinit(); defer tl.deinit();
if (opts.rebuild_rollup) { if (opts.rebuild_rollup) {

View file

@ -3,7 +3,7 @@ const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; 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) { const result = svc.getOptions(symbol) catch |err| switch (err) {
zfin.DataError.FetchFailed => { zfin.DataError.FetchFailed => {
try cli.stderrPrint("Error fetching options data from CBOE.\n"); 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; const ch = result.data;
defer { defer result.deinit();
for (ch) |chain| {
allocator.free(chain.underlying_symbol);
allocator.free(chain.calls);
allocator.free(chain.puts);
}
allocator.free(ch);
}
if (result.source == .cached) try cli.stderrPrint("(using cached options data)\n"); 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) /// - Benchmark comparison table (SPY/AGG vs portfolio weighted returns)
/// - Conservative weighted return estimate /// - Conservative weighted return estimate
/// - Safe withdrawal amounts at multiple horizons and confidence levels /// - 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 std = @import("std");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
const Date = zfin.Date;
const performance = @import("../analytics/performance.zig"); const performance = @import("../analytics/performance.zig");
const projections = @import("../analytics/projections.zig"); const projections = @import("../analytics/projections.zig");
const benchmark = @import("../analytics/benchmark.zig"); const benchmark = @import("../analytics/benchmark.zig");
const valuation = @import("../analytics/valuation.zig"); const valuation = @import("../analytics/valuation.zig");
const view = @import("../views/projections.zig"); const view = @import("../views/projections.zig");
const history = @import("../history.zig");
/// Hardcoded benchmark symbols (configurable in a future version). /// Hardcoded benchmark symbols (configurable in a future version).
const stock_benchmark = "SPY"; const stock_benchmark = "SPY";
const bond_benchmark = "AGG"; 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 { /// How an as-of date resolved against the history directory. The CLI
var loaded = cli.loadPortfolio(allocator, file_path) orelse return; /// uses this to render a single header that tells the user what
defer loaded.deinit(allocator); /// 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; pub fn run(
const positions = loaded.positions; allocator: std.mem.Allocator,
const syms = loaded.syms; svc: *zfin.DataService,
file_path: []const u8,
// Build prices from cache events_enabled: bool,
var prices = std.StringHashMap(f64).init(allocator); as_of: ?Date,
defer prices.deinit(); color: bool,
for (positions) |pos| { out: *std.Io.Writer,
if (pos.shares <= 0) continue; ) !void {
if (svc.getCachedCandles(pos.symbol)) |cs| { // Single arena for all view/render allocations. Same lifetime
defer allocator.free(cs); // regardless of live vs. as-of path.
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;
var arena_state = std.heap.ArenaAllocator.init(allocator); var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit(); defer arena_state.deinit();
const va = arena_state.allocator(); const va = arena_state.allocator();
const ctx = try view.loadProjectionContext( // portfolio_dir is the directory component of file_path, ending
va, // in a separator (for the downstream `{s}projections.srf` join).
file_path[0..dir_end], const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
pf_data.summary.allocations, const portfolio_dir = file_path[0..dir_end];
pf_data.summary.total_value,
portfolio.totalCash(), // Build the context via either the live or as-of pipeline. Both
portfolio.totalCdFaceValue(), // produce a `ProjectionContext`; from that point on rendering is
svc, // identical.
events_enabled, 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 horizons = ctx.config.getHorizons();
const confidence_levels = ctx.config.getConfidenceLevels(); const confidence_levels = ctx.config.getConfidenceLevels();
const comparison = ctx.comparison; const comparison = ctx.comparison;
try out.print("\n", .{}); try out.print("\n", .{});
try cli.setBold(out, color); 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 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 // Header row
try cli.setFg(out, color, cli.CLR_MUTED); 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); 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(); const events = ctx.config.getEvents();
if (events.len > 0) { 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 out.print("\n", .{});
try cli.setBold(out, color); try cli.setBold(out, color);
try out.print("Life Events\n", .{}); 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", .{}); 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. /// Write a return row using the view model, applying StyleIntent colors.
fn writeReturnRow(out: *std.Io.Writer, color: bool, row: view.ReturnRow) !void { fn writeReturnRow(out: *std.Io.Writer, color: bool, row: view.ReturnRow) !void {
try out.print("{s: <32}", .{row.label}); 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); 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; return;
}, },
}; };
defer allocator.free(candle_result.data); defer candle_result.deinit();
const candles = candle_result.data; const candles = candle_result.data;
// Fetch real-time quote via DataService // 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 // Not applied in auto mode: auto mode's as_of already comes from
// cache mode and is guaranteed to be a trading day. // 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 date_buf: [10]u8 = undefined;
var msg_buf: [256]u8 = undefined; var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint( const msg = std.fmt.bufPrint(
@ -229,8 +229,8 @@ pub fn run(
for (syms) |sym| { for (syms) |sym| {
if (svc.getCachedCandles(sym)) |cs| { if (svc.getCachedCandles(sym)) |cs| {
defer allocator.free(cs); defer cs.deinit();
if (zfin.valuation.candleCloseOnOrBefore(cs, as_of)) |cad| { if (zfin.valuation.candleCloseOnOrBefore(cs.data, as_of)) |cad| {
try symbol_prices.put(sym, 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 /// symbols. The absence of US equity candles across the board is what
/// signals a non-trading day for our purposes. /// signals a non-trading day for our purposes.
pub fn hasAnyTradingDayCandle( pub fn hasAnyTradingDayCandle(
allocator: std.mem.Allocator,
svc: *zfin.DataService, svc: *zfin.DataService,
symbols: []const []const u8, symbols: []const []const u8,
date: Date, date: Date,
@ -494,15 +493,15 @@ pub fn hasAnyTradingDayCandle(
for (symbols) |sym| { for (symbols) |sym| {
if (portfolio_mod.isMoneyMarketSymbol(sym)) continue; if (portfolio_mod.isMoneyMarketSymbol(sym)) continue;
const cs = svc.getCachedCandles(sym) orelse 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 // Linear scan from the end recent dates are where `date` is
// most likely to land for a backfill. // most likely to land for a backfill.
var i: usize = cs.len; var i: usize = cs.data.len;
while (i > 0) { while (i > 0) {
i -= 1; 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. // 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; return false;
@ -523,8 +522,8 @@ pub fn collectQuoteDates(
const is_mm = portfolio_mod.isMoneyMarketSymbol(sym); const is_mm = portfolio_mod.isMoneyMarketSymbol(sym);
var last_date: ?Date = null; var last_date: ?Date = null;
if (svc.getCachedCandles(sym)) |cs| { if (svc.getCachedCandles(sym)) |cs| {
defer allocator.free(cs); defer cs.deinit();
if (cs.len > 0) last_date = cs[cs.len - 1].date; 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 }; 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 cli = @import("common.zig");
const fmt = cli.fmt; 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) { const result = svc.getSplits(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); 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; return;
}, },
}; };
defer allocator.free(result.data); defer result.deinit();
if (result.source == .cached) try cli.stderrPrint("(using cached split data)\n"); 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 //! The CLI + TUI shared "compose two points in time" module. Loads a
//! snapshot into a `SnapshotSide` (aggregated per-symbol holdings + //! 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 //! 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: //! Responsibility split:
//! - `src/history.zig` snapshot IO (read file at a date) //! - `src/history.zig` snapshot IO + pure-domain
//! - `src/compare.zig` semantic composition (snapshot //! aggregation (`liquidFromSnapshot`,
//! aggregated side; live aggregation) //! `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 //! - `src/views/compare.zig` pure view model (build CompareView
//! from two holdings maps + totals) //! from two holdings maps + totals)
//! - `src/commands/compare.zig` CLI dispatch + live-side pipeline //! - `src/commands/compare.zig` CLI dispatch + live-side pipeline
@ -23,7 +34,7 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("root.zig"); const zfin = @import("root.zig");
const history_io = @import("history.zig"); const history = @import("history.zig");
const snapshot_model = @import("models/snapshot.zig"); const snapshot_model = @import("models/snapshot.zig");
const fmt = @import("format.zig"); const fmt = @import("format.zig");
const view = @import("views/compare.zig"); const view = @import("views/compare.zig");
@ -41,7 +52,7 @@ pub const Date = zfin.Date;
pub const SnapshotSide = struct { pub const SnapshotSide = struct {
map: view.HoldingMap, map: view.HoldingMap,
liquid: f64, liquid: f64,
loaded: history_io.LoadedSnapshot, loaded: history.LoadedSnapshot,
pub fn deinit(self: *SnapshotSide, allocator: std.mem.Allocator) void { pub fn deinit(self: *SnapshotSide, allocator: std.mem.Allocator) void {
self.map.deinit(); self.map.deinit();
@ -60,7 +71,7 @@ pub fn loadSnapshotSide(
hist_dir: []const u8, hist_dir: []const u8,
date: Date, date: Date,
) !SnapshotSide { ) !SnapshotSide {
var loaded = try history_io.loadSnapshotAt(allocator, hist_dir, date); var loaded = try history.loadSnapshotAt(allocator, hist_dir, date);
errdefer loaded.deinit(allocator); errdefer loaded.deinit(allocator);
var map: view.HoldingMap = .init(allocator); var map: view.HoldingMap = .init(allocator);
@ -69,18 +80,23 @@ pub fn loadSnapshotSide(
return .{ return .{
.map = map, .map = map,
.liquid = liquidFromSnapshot(&loaded.snap), .liquid = history.liquidFromSnapshot(&loaded.snap),
.loaded = loaded, .loaded = loaded,
}; };
} }
// Aggregation helpers // Stock aggregation (compare-view shape)
/// Walk a snapshot's lot rows, filter to `security_type == "Stock"`, /// Walk a snapshot's lot rows, filter to `security_type == "Stock"`,
/// and group by symbol into `out_map`. Shares are summed; price is /// 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 /// taken from the first lot seen (all stock lots of a symbol share
/// the same `price` field in a given snapshot). /// 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. /// `out_map` keys borrow from the snapshot's backing byte buffer.
/// Caller must keep the snapshot alive as long as the map is used. /// Caller must keep the snapshot alive as long as the map is used.
pub fn aggregateSnapshotStocks( pub fn aggregateSnapshotStocks(
@ -99,6 +115,8 @@ pub fn aggregateSnapshotStocks(
} }
} }
// Live-portfolio aggregation
/// Walk the live portfolio's stock lots, group by `priceSymbol()`, /// Walk the live portfolio's stock lots, group by `priceSymbol()`,
/// and look up the current price from `prices`. Mirrors the snapshot /// and look up the current price from `prices`. Mirrors the snapshot
/// aggregation so the two sides are apples-to-apples. /// 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 // Tests
const testing = std.testing; 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); 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" { test "loadSnapshotSide: happy path builds a SnapshotSide with aggregated holdings" {
var tmp = std.testing.tmpDir(.{}); var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup(); defer tmp.cleanup();

View file

@ -277,6 +277,13 @@ pub fn todayDate() Date {
return .{ .days = days }; 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. /// Return "LT" if held > 1 year from open_date to today, "ST" otherwise.
pub fn capitalGainsIndicator(open_date: Date) []const u8 { pub fn capitalGainsIndicator(open_date: Date) []const u8 {
const today = todayDate(); const today = todayDate();

View file

@ -15,6 +15,7 @@
//! than inlining `std.process.Child.run` in the command module. //! than inlining `std.process.Child.run` in the command module.
const std = @import("std"); const std = @import("std");
const Date = @import("models/date.zig").Date;
// Types // Types
@ -36,6 +37,9 @@ pub const Error = error{
PathMissingInRev, PathMissingInRev,
/// `git log` returned non-zero. /// `git log` returned non-zero.
GitLogFailed, GitLogFailed,
/// `resolveCommitRange` was asked for a `since` date with no commit
/// at or before it nothing to diff against.
NoCommitAtOrBefore,
OutOfMemory, OutOfMemory,
}; };
@ -59,11 +63,23 @@ pub const PathStatus = enum {
/// Wall-clock time a commit touched a given path. /// Wall-clock time a commit touched a given path.
pub const CommitTouch = struct { 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). /// Committer timestamp (Unix epoch seconds).
timestamp: i64, 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 // Implementation
/// Locate the git repository containing `path` and the path's position /// 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 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 // Tests
// //
// These tests shell out to `git init` and friends in a tmp dir. They // 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). // Timestamps are plausible (after 2020).
try std.testing.expect(commits[0].timestamp > 1_577_836_800); 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 //! 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`. //! - `parseSnapshotBytes(bytes)` parse an SRF blob into a `Snapshot`.
//! The snapshot's string fields slice directly into `bytes`, so the //! The snapshot's string fields slice directly into `bytes`, so the
@ -9,6 +11,11 @@
//! - `loadHistoryDir(dir)` enumerate `*-portfolio.srf` in a directory //! - `loadHistoryDir(dir)` enumerate `*-portfolio.srf` in a directory
//! and parse each. The returned `LoadedHistory` owns both the //! and parse each. The returned `LoadedHistory` owns both the
//! snapshots and their backing byte buffers as matched pairs. //! 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 //! The snapshot reader is discriminator-driven: every record must carry
//! a `kind::<meta|total|tax_type|account|lot>` field. Records whose //! a `kind::<meta|total|tax_type|account|lot>` field. Records whose
@ -26,7 +33,9 @@ const std = @import("std");
const srf = @import("srf"); const srf = @import("srf");
const snapshot = @import("models/snapshot.zig"); const snapshot = @import("models/snapshot.zig");
const Date = @import("models/date.zig").Date; const Date = @import("models/date.zig").Date;
const Candle = @import("models/candle.zig").Candle;
const timeline = @import("analytics/timeline.zig"); const timeline = @import("analytics/timeline.zig");
const valuation = @import("analytics/valuation.zig");
pub const Error = error{ pub const Error = error{
/// The file didn't open a `#!srfv1` directive or couldn't be /// The file didn't open a `#!srfv1` directive or couldn't be
@ -363,6 +372,208 @@ pub fn findNearestSnapshot(
return .{ .earlier = earlier, .later = later }; 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 // Tests
const testing = std.testing; 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(i32, Date.fromYmd(2024, 3, 15).days), loaded.snap.meta.as_of_date.days);
try testing.expectEqual(@as(usize, 3), loaded.snap.totals.len); 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) \\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio Load and analyze the portfolio \\ portfolio Load and analyze the portfolio
\\ analysis Show portfolio analysis \\ 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/ \\ snapshot [opts] Write a daily portfolio snapshot to history/
\\ compare <DATE> [<DATE>] Compare portfolio against snapshot (one date = vs today) \\ compare <DATE> [<DATE>] Compare portfolio against snapshot (one date = vs today)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI \\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ audit [opts] Reconcile portfolio against brokerage export \\ 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 stats Show cache statistics
\\ cache clear Clear all cached data \\ cache clear Clear all cached data
\\ version [-v] Show zfin version and build info \\ version [-v] Show zfin version and build info
@ -71,8 +71,21 @@ const usage =
\\ Contributions additionally requires the portfolio file to be tracked \\ Contributions additionally requires the portfolio file to be tracked
\\ in a git repo; `git` must be on PATH. \\ 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: \\Projections command options:
\\ --no-events Exclude life events from simulation (baseline view) \\ --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: \\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) \\ 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"); try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
return 1; 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")) { } else if (std.mem.eql(u8, command, "splits")) {
if (cmd_args.len < 1) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'splits' requires a symbol argument\n"); try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
return 1; 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")) { } else if (std.mem.eql(u8, command, "options")) {
if (cmd_args.len < 1) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'options' requires a symbol argument\n"); 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; 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")) { } else if (std.mem.eql(u8, command, "earnings")) {
if (cmd_args.len < 1) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n"); try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n");
return 1; 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")) { } else if (std.mem.eql(u8, command, "etf")) {
if (cmd_args.len < 1) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'etf' requires a symbol argument\n"); try cli.stderrPrint("Error: 'etf' requires a symbol argument\n");
return 1; 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")) { } else if (std.mem.eql(u8, command, "portfolio")) {
// Parse --refresh flag; reject any other token (including old // Parse --refresh flag; reject any other token (including old
// positional FILE, which is now a global -p). // 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); try commands.analysis.run(allocator, &svc, pf.path, color, out);
} else if (std.mem.eql(u8, command, "projections")) { } else if (std.mem.eql(u8, command, "projections")) {
var events_enabled = true; 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")) { if (std.mem.eql(u8, a, "--no-events")) {
events_enabled = false; 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 { } else {
try reportUnexpectedArg("projections", a); try reportUnexpectedArg("projections", a);
return 1; return 1;
@ -429,15 +468,56 @@ fn runCli() !u8 {
} }
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator); 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")) { } else if (std.mem.eql(u8, command, "contributions")) {
for (cmd_args) |a| { var since: ?zfin.Date = null;
try reportUnexpectedArg("contributions", a); 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; return 1;
} }
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator); 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")) { } else if (std.mem.eql(u8, command, "snapshot")) {
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator); defer if (pf.resolved) |r| r.deinit(allocator);

View file

@ -50,6 +50,30 @@ pub const EtfProfile = struct {
self.sectors != null or self.sectors != null or
self.total_holdings != null; 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"); const std = @import("std");

View file

@ -32,4 +32,21 @@ pub const OptionsChain = struct {
expiration: Date, expiration: Date,
calls: []const OptionContract, calls: []const OptionContract,
puts: []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. /// 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 { pub fn FetchResult(comptime T: type) type {
return struct { return struct {
data: cache.Store.DataFor(T), data: cache.Store.DataFor(T),
source: Source, source: Source,
timestamp: i64, 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| { if (s.read(T, symbol, postProcess, .fresh_only)) |cached| {
log.debug("{s}: {s} fresh in local cache", .{ symbol, @tagName(data_type) }); 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 // Try server sync before hitting providers
if (self.syncFromServer(symbol, data_type)) { if (self.syncFromServer(symbol, data_type)) {
if (s.read(T, symbol, postProcess, .fresh_only)) |cached| { if (s.read(T, symbol, postProcess, .fresh_only)) |cached| {
log.debug("{s}: {s} synced from server and fresh", .{ symbol, @tagName(data_type) }); 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) }); 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; return DataError.FetchFailed;
}; };
s.write(T, symbol, retried, data_type.ttl()); 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); s.writeNegative(symbol, data_type);
return DataError.FetchFailed; return DataError.FetchFailed;
}; };
s.write(T, symbol, fetched, data_type.ttl()); 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. /// Dispatch a fetch to the correct provider based on model type.
@ -434,14 +464,14 @@ pub const DataService = struct {
// Fresh deserialize candles and return // Fresh deserialize candles and return
log.debug("{s}: candles fresh in local cache", .{symbol}); log.debug("{s}: candles fresh in local cache", .{symbol});
if (s.read(Candle, symbol, null, .any)) |r| 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 { } else {
// Stale try server sync before incremental fetch // Stale try server sync before incremental fetch
if (self.syncCandlesFromServer(symbol)) { if (self.syncCandlesFromServer(symbol)) {
if (s.isCandleMetaFresh(symbol)) { if (s.isCandleMetaFresh(symbol)) {
log.debug("{s}: candles synced from server and fresh", .{symbol}); log.debug("{s}: candles synced from server and fresh", .{symbol});
if (s.read(Candle, symbol, null, .any)) |r| 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}); 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)) { if (!fetch_from.lessThan(today)) {
s.updateCandleMeta(symbol, m.last_close, m.last_date, m.provider, m.fail_count); s.updateCandleMeta(symbol, m.last_close, m.last_date, m.provider, m.fail_count);
if (s.read(Candle, symbol, null, .any)) |r| 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 { } else {
// Incremental fetch from day after last cached candle // Incremental fetch from day after last cached candle
const result = self.fetchCandlesFromProviders(symbol, fetch_from, today, m.provider) catch |err| { 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) { if (new_fail_count >= 3) {
log.warn("{s}: degraded after {d} consecutive failures, returning stale data", .{ symbol, new_fail_count }); log.warn("{s}: degraded after {d} consecutive failures, returning stale data", .{ symbol, new_fail_count });
if (s.read(Candle, symbol, null, .any)) |r| 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; return DataError.TransientError;
} }
// Non-transient failure return stale data if available // Non-transient failure return stale data if available
if (s.read(Candle, symbol, null, .any)) |r| 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; return DataError.FetchFailed;
}; };
const new_candles = result.candles; const new_candles = result.candles;
@ -483,15 +513,15 @@ pub const DataService = struct {
self.allocator().free(new_candles); self.allocator().free(new_candles);
s.updateCandleMeta(symbol, m.last_close, m.last_date, result.provider, 0); s.updateCandleMeta(symbol, m.last_close, m.last_date, result.provider, 0);
if (s.read(Candle, symbol, null, .any)) |r| 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 { } else {
// Append new candles to existing file + update meta, reset fail_count // Append new candles to existing file + update meta, reset fail_count
s.appendCandles(symbol, new_candles, result.provider, 0); s.appendCandles(symbol, new_candles, result.provider, 0);
if (s.read(Candle, symbol, null, .any)) |r| { if (s.read(Candle, symbol, null, .any)) |r| {
self.allocator().free(new_candles); 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)) { if (s.isCandleMetaFresh(symbol)) {
log.debug("{s}: candles synced from server and fresh (no prior cache)", .{symbol}); log.debug("{s}: candles synced from server and fresh (no prior cache)", .{symbol});
if (s.read(Candle, symbol, null, .any)) |r| 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}); 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 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. /// 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) { pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) {
// Mutual funds (5-letter tickers ending in X) don't have quarterly earnings. // Mutual funds (5-letter tickers ending in X) don't have quarterly earnings.
if (isMutualFund(symbol)) { 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(); var s = self.store();
@ -568,7 +598,7 @@ pub const DataService = struct {
if (!needs_refresh) { if (!needs_refresh) {
log.debug("{s}: earnings fresh in local cache", .{symbol}); 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 // Stale: free cached events and re-fetch below
self.allocator().free(cached.data); self.allocator().free(cached.data);
@ -578,7 +608,7 @@ pub const DataService = struct {
if (self.syncFromServer(symbol, .earnings)) { if (self.syncFromServer(symbol, .earnings)) {
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
log.debug("{s}: earnings synced from server and fresh", .{symbol}); 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}); 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); 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. /// Fetch ETF profile for a symbol.
@ -608,7 +638,7 @@ pub const DataService = struct {
var s = self.store(); var s = self.store();
if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached| 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); var av = try self.getProvider(AlphaVantage);
const fetched = av.fetchEtfProfile(self.allocator(), symbol) catch |err| blk: { 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); 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. /// 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. /// 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). /// 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(); var s = self.store();
if (s.isNegative(symbol, .candles_daily)) return null; if (s.isNegative(symbol, .candles_daily)) return null;
const result = s.read(Candle, symbol, null, .any) orelse 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). /// Read dividends from cache only (no network fetch).
@ -1548,6 +1581,7 @@ test "FetchResult type construction" {
.data = &.{}, .data = &.{},
.source = .cached, .source = .cached,
.timestamp = 0, .timestamp = 0,
.allocator = std.testing.allocator,
}; };
try std.testing.expect(candle_result.source == .cached); try std.testing.expect(candle_result.source == .cached);
@ -1555,6 +1589,7 @@ test "FetchResult type construction" {
.data = &.{}, .data = &.{},
.source = .fetched, .source = .fetched,
.timestamp = 12345, .timestamp = 12345,
.allocator = std.testing.allocator,
}; };
try std.testing.expect(div_result.source == .fetched); try std.testing.expect(div_result.source == .fetched);
try std.testing.expectEqual(@as(i64, 12345), div_result.timestamp); 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 analysis_tab = @import("tui/analysis_tab.zig");
const history_tab = @import("tui/history_tab.zig"); const history_tab = @import("tui/history_tab.zig");
const projections_tab = @import("tui/projections_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 timeline = @import("analytics/timeline.zig");
const compare_core = @import("compare.zig"); const compare_core = @import("compare.zig");
const compare_view = @import("views/compare.zig"); const compare_view = @import("views/compare.zig");
@ -106,6 +106,10 @@ pub const InputMode = enum {
help, help,
account_picker, account_picker,
account_search, 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 { pub const StyledLine = struct {
@ -408,7 +412,7 @@ pub const App = struct {
// History tab state // History tab state
history_loaded: bool = false, history_loaded: bool = false,
history_disabled: bool = false, // true when no portfolio path (history requires it) 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 // Cursor for the recent-snapshots table. 0 = newest row (live
// pseudo-row if available, otherwise newest snapshot). // pseudo-row if available, otherwise newest snapshot).
history_cursor: usize = 0, history_cursor: usize = 0,
@ -447,6 +451,17 @@ pub const App = struct {
projections_events_enabled: bool = true, projections_events_enabled: bool = true,
projections_value_min: f64 = 0, projections_value_min: f64 = 0,
projections_value_max: 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 // Default to `.liquid` that's the metric most worth watching
// day-to-day. Illiquid barely changes, net_worth is dominated by // day-to-day. Illiquid barely changes, net_worth is dominated by
// liquid anyway, so "show me liquid" is the headline view. // liquid anyway, so "show me liquid" is the headline view.
@ -480,6 +495,9 @@ pub const App = struct {
if (self.mode == .symbol_input) { if (self.mode == .symbol_input) {
return self.handleInputKey(ctx, key); return self.handleInputKey(ctx, key);
} }
if (self.mode == .date_input) {
return self.handleDateInputKey(ctx, key);
}
if (self.mode == .account_picker) { if (self.mode == .account_picker) {
return self.handleAccountPickerKey(ctx, key); return self.handleAccountPickerKey(ctx, key);
} }
@ -669,48 +687,156 @@ pub const App = struct {
} }
} }
/// Handles keypresses in symbol_input mode (activated by `/`). /// Outcome of a single keypress in an input-mode buffer (symbol
/// Mini text input for typing a ticker symbol (e.g. AAPL, BRK.B, ^GSPC). /// input, date input, etc.). Returned by `handleInputBuffer` so
fn handleInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { /// the per-mode caller only needs to wire up the `committed`
// Cancel input, return to normal mode /// 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) { if (key.codepoint == vaxis.Key.escape) {
self.mode = .normal; self.mode = .normal;
self.input_len = 0; self.input_len = 0;
self.setStatus("Cancelled"); return .cancelled;
return ctx.consumeAndRedraw();
} }
// Commit: uppercase the input, set as active symbol, switch to quote tab
if (key.codepoint == vaxis.Key.enter) { if (key.codepoint == vaxis.Key.enter) {
if (self.input_len > 0) { return .committed;
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();
} }
// Delete last character
if (key.codepoint == vaxis.Key.backspace) { if (key.codepoint == vaxis.Key.backspace) {
if (self.input_len > 0) self.input_len -= 1; if (self.input_len > 0) self.input_len -= 1;
return ctx.consumeAndRedraw(); return .edited;
} }
// Ctrl+U: clear entire input (readline convention) // Ctrl+U: clear entire input (readline convention)
if (key.matches('u', .{ .ctrl = true })) { if (key.matches('u', .{ .ctrl = true })) {
self.input_len = 0; 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) { 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_buf[self.input_len] = @intCast(key.codepoint);
self.input_len += 1; 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; 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 (key.codepoint == vaxis.Key.escape) {
if (self.active_tab == .portfolio and self.account_filter != null) { if (self.active_tab == .portfolio and self.account_filter != null) {
self.setAccountFilter(null); self.setAccountFilter(null);
@ -962,6 +1089,15 @@ pub const App = struct {
self.setStatus("Filter cleared: showing all accounts"); self.setStatus("Filter cleared: showing all accounts");
return ctx.consumeAndRedraw(); 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; return;
} }
@ -1194,6 +1330,18 @@ pub const App = struct {
return ctx.consumeAndRedraw(); 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 // History-tab compare actions are normally intercepted in
// `handleCompareKey` before `matchAction` runs (because the // `handleCompareKey` before `matchAction` runs (because the
// default 's'/'c'/space/escape key bindings belong to other // default 's'/'c'/space/escape key bindings belong to other
@ -1642,12 +1790,7 @@ pub const App = struct {
pub fn freeOptions(self: *App) void { pub fn freeOptions(self: *App) void {
if (self.options_data) |chains| { if (self.options_data) |chains| {
for (chains) |chain| { zfin.OptionsChain.freeSlice(self.allocator, chains);
self.allocator.free(chain.calls);
self.allocator.free(chain.puts);
self.allocator.free(chain.underlying_symbol);
}
self.allocator.free(chains);
} }
self.options_data = null; 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 { fn drawStatusBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface {
const t = self.theme; const t = self.theme;
const buf = try ctx.arena.alloc(vaxis.Cell, width); const buf = try ctx.arena.alloc(vaxis.Cell, width);
if (self.mode == .symbol_input) { if (self.mode == .symbol_input) {
const prompt_style = t.inputStyle(); self.renderInputPrompt(buf, width, "Symbol: ", " Enter=confirm Esc=cancel ");
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style }); } else if (self.mode == .date_input) {
self.renderInputPrompt(buf, width, "As-of: ", " YYYY-MM-DD | 1M | live Enter=confirm ");
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 };
}
}
} else if (self.mode == .account_picker) { } else if (self.mode == .account_picker) {
const prompt_style = t.inputStyle(); const prompt_style = t.inputStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style }); @memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });

View file

@ -40,7 +40,7 @@ const zfin = @import("../root.zig");
const fmt = @import("../format.zig"); const fmt = @import("../format.zig");
const theme = @import("theme.zig"); const theme = @import("theme.zig");
const tui = @import("../tui.zig"); const tui = @import("../tui.zig");
const history_io = @import("../history.zig"); const history = @import("../history.zig");
const timeline = @import("../analytics/timeline.zig"); const timeline = @import("../analytics/timeline.zig");
const view = @import("../views/history.zig"); const view = @import("../views/history.zig");
const compare_core = @import("../compare.zig"); const compare_core = @import("../compare.zig");
@ -59,7 +59,7 @@ pub fn loadData(app: *App) void {
return; 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"); app.setStatus("Failed to read history/ directory");
return; 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"); app.setStatus("No portfolio loaded — can't build compare");
return error.PortfolioLoadFailed; 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); defer app.allocator.free(hist_dir);
// SAFETY: assigned in both branches of the `if (older.is_live)` // SAFETY: assigned in both branches of the `if (older.is_live)`

View file

@ -58,6 +58,15 @@ pub const Action = enum {
account_filter, account_filter,
toggle_chart, toggle_chart,
toggle_events, 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 { pub const KeyCombo = struct {
@ -157,6 +166,10 @@ const default_bindings = [_]Binding{
.{ .action = .account_filter, .key = .{ .codepoint = 'a' } }, .{ .action = .account_filter, .key = .{ .codepoint = 'a' } },
.{ .action = .toggle_chart, .key = .{ .codepoint = 'v' } }, .{ .action = .toggle_chart, .key = .{ .codepoint = 'v' } },
.{ .action = .toggle_events, .key = .{ .codepoint = 'e' } }, .{ .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 { pub fn defaults() KeyMap {

View file

@ -111,7 +111,7 @@ pub fn loadPortfolioData(app: *App) void {
if (app.watchlist) |wl| { if (app.watchlist) |wl| {
for (wl) |sym| { for (wl) |sym| {
const result = app.svc.getCandles(sym) catch continue; const result = app.svc.getCandles(sym) catch continue;
defer app.allocator.free(result.data); defer result.deinit();
if (result.data.len > 0) { if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {}; 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) { if (lot.security_type == .watch) {
const sym = lot.priceSymbol(); const sym = lot.priceSymbol();
const result = app.svc.getCandles(sym) catch continue; const result = app.svc.getCandles(sym) catch continue;
defer app.allocator.free(result.data); defer result.deinit();
if (result.data.len > 0) { if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {}; 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 // Cache only no network
const candles_slice = app.svc.getCachedCandles(sym); const candles_slice = app.svc.getCachedCandles(sym);
if (candles_slice) |cs| { if (candles_slice) |cs| {
defer app.allocator.free(cs); defer cs.deinit();
if (cs.len > 0) { if (cs.data.len > 0) {
prices.put(sym, cs[cs.len - 1].close) catch {}; prices.put(sym, cs.data[cs.data.len - 1].close) catch {};
const d = cs[cs.len - 1].date; const d = cs.data[cs.data.len - 1].date;
if (latest_date == null or d.days > latest_date.?.days) latest_date = d; if (latest_date == null or d.days > latest_date.?.days) latest_date = d;
} }
} else { } else {

View file

@ -10,6 +10,14 @@
//! Consumes `src/analytics/projections.zig` (simulation engine), //! Consumes `src/analytics/projections.zig` (simulation engine),
//! `src/analytics/benchmark.zig` (weighted returns), and //! `src/analytics/benchmark.zig` (weighted returns), and
//! `src/views/projections.zig` (view model). //! `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 std = @import("std");
const vaxis = @import("vaxis"); const vaxis = @import("vaxis");
@ -24,6 +32,7 @@ const benchmark = @import("../analytics/benchmark.zig");
const performance = @import("../analytics/performance.zig"); const performance = @import("../analytics/performance.zig");
const valuation = @import("../analytics/valuation.zig"); const valuation = @import("../analytics/valuation.zig");
const view = @import("../views/projections.zig"); const view = @import("../views/projections.zig");
const history = @import("../history.zig");
const App = tui.App; const App = tui.App;
const StyledLine = tui.StyledLine; const StyledLine = tui.StyledLine;
@ -38,17 +47,79 @@ pub fn loadData(app: *App) void {
return; 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 { const summary = app.portfolio_summary orelse {
app.setStatus("No portfolio summary — visit Portfolio tab first"); app.setStatus("No portfolio summary — visit Portfolio tab first");
return; return;
}; };
const portfolio = app.portfolio orelse 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( const ctx = view.loadProjectionContext(
app.allocator, app.allocator,
portfolio_path[0..dir_end], portfolio_dir,
summary.allocations, summary.allocations,
summary.total_value, summary.total_value,
portfolio.totalCash(), portfolio.totalCash(),
@ -63,6 +134,50 @@ pub fn loadData(app: *App) void {
app.projections_ctx = ctx; 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 { pub fn freeLoaded(app: *App) void {
if (app.projections_ctx) |ctx| { if (app.projections_ctx) |ctx| {
app.allocator.free(ctx.data.withdrawals); 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 config = ctx.config;
const stock_pct = ctx.stock_pct; 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 // Header
try lines.append(arena, .{ try lines.append(arena, .{
.text = " Benchmark Comparison", .text = " Benchmark Comparison",

View file

@ -90,6 +90,27 @@ pub const TotalsRow = struct {
style: StyleIntent, 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()`. /// Complete compare view. `symbols` is caller-owned; call `deinit()`.
pub const CompareView = struct { pub const CompareView = struct {
then_date: Date, then_date: Date,
@ -116,6 +137,10 @@ pub const CompareView = struct {
/// Symbols present in "then" but not "now" position closed /// Symbols present in "then" but not "now" position closed
/// between the two dates. Never rendered as rows; shown as a count. /// between the two dates. Never rendered as rows; shown as a count.
removed_count: usize, 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 { pub fn deinit(self: *CompareView, allocator: std.mem.Allocator) void {
allocator.free(self.symbols); allocator.free(self.symbols);
@ -575,10 +600,9 @@ pub fn nowLabel(cv: CompareView, buf: *[10]u8) []const u8 {
return cv.now_date.format(buf); return cv.now_date.format(buf);
} }
/// English pluralization for the "(N day[s])" header suffix. /// Re-export of `format.dayPlural` so callers keep a single import.
pub fn dayPlural(n: i32) []const u8 { /// The canonical implementation lives in `src/format.zig`.
return if (n == 1) "" else "s"; pub const dayPlural = fmt.dayPlural;
}
test "buildSymbolRowCells: wires through the right formatters" { test "buildSymbolRowCells: wires through the right formatters" {
var p_then: [24]u8 = undefined; 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 projections = @import("../analytics/projections.zig");
const valuation = @import("../analytics/valuation.zig"); const valuation = @import("../analytics/valuation.zig");
const zfin = @import("../root.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; pub const StyleIntent = fmt.StyleIntent;
@ -223,6 +226,104 @@ pub fn loadProjectionContext(
cd_value: f64, cd_value: f64,
svc: *zfin.DataService, svc: *zfin.DataService,
events_enabled: bool, 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 { ) !ProjectionContext {
// Load projections.srf // Load projections.srf
const proj_path = try std.fmt.allocPrint(alloc, "{s}projections.srf", .{portfolio_dir}); const proj_path = try std.fmt.allocPrint(alloc, "{s}projections.srf", .{portfolio_dir});
@ -252,25 +353,37 @@ pub fn loadProjectionContext(
cd_value, 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_result = svc.getCandles("SPY") catch null;
const spy_candles = if (spy_result) |r| r.data else &.{}; defer if (spy_result) |r| r.deinit();
defer if (spy_result) |r| alloc.free(r.data); const spy_candles = history.sliceCandlesAsOf(
if (spy_result) |r| r.data else &.{},
as_of,
);
const agg_result = svc.getCandles("AGG") catch null; const agg_result = svc.getCandles("AGG") catch null;
const agg_candles = if (agg_result) |r| r.data else &.{}; defer if (agg_result) |r| r.deinit();
defer if (agg_result) |r| alloc.free(r.data); const agg_candles = history.sliceCandlesAsOf(
if (agg_result) |r| r.data else &.{},
as_of,
);
const spy_trailing = performance.trailingReturns(spy_candles); const spy_trailing = performance.trailingReturns(spy_candles);
const agg_trailing = performance.trailingReturns(agg_candles); const agg_trailing = performance.trailingReturns(agg_candles);
const spy_week = performance.weekReturn(spy_candles); const spy_week = performance.weekReturn(spy_candles);
const agg_week = performance.weekReturn(agg_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; var pos_returns: std.ArrayListUnmanaged(benchmark.PositionReturn) = .empty;
defer pos_returns.deinit(alloc); defer pos_returns.deinit(alloc);
for (allocations) |a| { for (allocations) |a| {
const candles = svc.getCachedCandles(a.symbol) orelse continue; const candles_res = svc.getCachedCandles(a.symbol) orelse continue;
defer alloc.free(candles); defer candles_res.deinit();
const candles = history.sliceCandlesAsOf(candles_res.data, as_of);
if (candles.len > 0) { if (candles.len > 0) {
try pos_returns.append(alloc, .{ try pos_returns.append(alloc, .{
.symbol = a.symbol, .symbol = a.symbol,
@ -290,11 +403,29 @@ pub fn loadProjectionContext(
agg_week, agg_week,
); );
// Resolve events to simulation years // Event resolution differs by mode:
const resolved = config.resolveEvents(); // - Live: current ages (resolveEvents uses config.currentAges()).
const resolved_events = resolved[0..config.event_count]; // - 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) // Table row builders (shared by CLI and TUI)