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