add projections comparison to compare and projections commands

This commit is contained in:
Emil Lerch 2026-05-02 11:29:55 -07:00
parent 6e0861c5dd
commit 1839bce49b
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 494 additions and 28 deletions

View file

@ -55,6 +55,7 @@ const compare_core = @import("../compare.zig");
const view = @import("../views/compare.zig");
const view_hist = @import("../views/history.zig");
const contributions = @import("contributions.zig");
const projections = @import("projections.zig");
pub const Error = error{
UnexpectedArg,
@ -75,21 +76,72 @@ pub fn run(
out: *std.Io.Writer,
) !void {
// Parse args
if (cmd_args.len == 0) {
//
// `--projections` is an opt-in flag that embeds the
// projected-return + safe-withdrawal delta block between the
// Liquid totals and the per-symbol table. Kept opt-in because
// building the projection side for both dates roughly doubles
// command runtime (Monte Carlo SWR search + benchmark trailing
// returns for both endpoints). Strip it before positional date
// parsing so it can appear anywhere in the arg list. We do NOT
// accept `-p` as a shortcut because `-p` is already the global
// `--portfolio` flag at the top level shadowing it here would
// confuse users who reach for the short form.
//
// `--no-events` mirrors the `projections` command's flag of the
// same name: it suppresses life events in the underlying
// projection simulation. Only meaningful alongside
// `--projections` (silently ignored otherwise).
var with_projections = false;
var events_enabled = true;
var positional: std.ArrayList([]const u8) = .empty;
defer positional.deinit(allocator);
for (cmd_args) |a| {
if (std.mem.eql(u8, a, "--projections")) {
with_projections = true;
} else if (std.mem.eql(u8, a, "--no-events")) {
events_enabled = false;
} else if (a.len > 0 and a[0] == '-' and !std.mem.eql(u8, a, "-")) {
// Any other dash-prefixed token is an unknown compare flag.
// Catch it explicitly with a pointed message rather than
// letting it fall through to the date parser (which would
// emit a generic "invalid date" error). Most likely cause:
// user reached for `-p` expecting `--projections` that
// shortcut was intentionally not exposed to avoid
// shadowing the global `-p` / `--portfolio` flag.
try cli.stderrPrint("Error: unknown flag for 'compare': ");
try cli.stderrPrint(a);
try cli.stderrPrint("\nKnown flags: --projections, --no-events.\n");
if (std.mem.eql(u8, a, "-p")) {
try cli.stderrPrint(" (Tip: the projections flag is spelled `--projections` in full.\n");
try cli.stderrPrint(" `-p` is reserved for the global --portfolio option and must appear\n");
try cli.stderrPrint(" before the subcommand, e.g. `zfin -p /path/to/portfolio.srf compare ...`.)\n");
}
return error.UnexpectedArg;
} else {
try positional.append(allocator, a);
}
}
const args = positional.items;
if (args.len == 0) {
try cli.stderrPrint("Error: 'compare' requires one or two dates.\n");
try cli.stderrPrint("Usage:\n");
try cli.stderrPrint(" zfin compare <DATE> (compare date vs current)\n");
try cli.stderrPrint(" zfin compare <DATE1> <DATE2> (compare two dates)\n");
try cli.stderrPrint(" zfin compare [--projections [--no-events]] <DATE> (compare date vs current)\n");
try cli.stderrPrint(" zfin compare [--projections [--no-events]] <DATE1> <DATE2> (compare two dates)\n");
try cli.stderrPrint("Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n");
try cli.stderrPrint("Flags:\n");
try cli.stderrPrint(" --projections Include projected return + safe-withdrawal deltas.\n");
try cli.stderrPrint(" --no-events (with --projections) Exclude life events from the simulation.\n");
return error.MissingDateArg;
}
if (cmd_args.len > 2) {
try cli.stderrPrint("Error: 'compare' takes at most two arguments.\n");
if (args.len > 2) {
try cli.stderrPrint("Error: 'compare' takes at most two dates.\n");
return error.UnexpectedArg;
}
const today = fmt.todayDate();
const date1 = cli.parseRequiredDateOrStderr(cmd_args[0], today, "date1") catch |err| switch (err) {
const date1 = cli.parseRequiredDateOrStderr(args[0], today, "date1") catch |err| switch (err) {
error.InvalidDate => return error.InvalidDate,
};
@ -97,8 +149,8 @@ pub fn run(
// user-given date is "then" and today is "now" (from the live
// portfolio). In two-date mode both are snapshots and we swap to
// guarantee older newer.
const date2: ?Date = if (cmd_args.len == 2)
(cli.parseRequiredDateOrStderr(cmd_args[1], today, "date2") catch |err| switch (err) {
const date2: ?Date = if (args.len == 2)
(cli.parseRequiredDateOrStderr(args[1], today, "date2") catch |err| switch (err) {
error.InvalidDate => return error.InvalidDate,
})
else
@ -191,6 +243,39 @@ pub fn run(
var then_side = try compare_core.loadSnapshotSide(allocator, hist_dir, then_date);
defer then_side.deinit(allocator);
// Projections: only computed when --projections/-p flag is set.
// Uses the SNAPPED dates (not requested) because projections are
// snapshot-based they need actual files on disk to load.
// When `now_is_live`, pass null for the "now" side so projections
// uses the live portfolio. When two-date mode, pass the snapped
// now_date.
var projections_result: ?projections.KeyComparisonResult = null;
defer if (projections_result) |r| r.cleanup();
var projections_block: ?ProjectionsBlock = null;
if (with_projections) {
const now_date_for_proj: ?Date = if (now_is_live) null else now_date;
projections_result = projections.computeKeyComparison(
allocator,
arena,
svc,
portfolio_path,
events_enabled,
then_date,
now_date_for_proj,
) catch |err| blk: {
// Projections computation failed fall back to compare
// output without the block. User still gets the core
// Liquid/attribution/per-symbol view.
var ebuf: [160]u8 = undefined;
const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} — continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n";
cli.stderrPrint(msg) catch {};
break :blk null;
};
if (projections_result) |r| {
projections_block = .{ .then = r.then, .now = r.now };
}
}
if (now_is_live) {
var now_live = try LiveSide.load(allocator, svc, portfolio_path, color);
defer now_live.deinit(allocator);
@ -211,6 +296,7 @@ pub fn run(
.then_map = &then_side.map,
.now_map = &now_live.map,
.attribution = attribution,
.projections = projections_block,
});
} else {
var now_side = try compare_core.loadSnapshotSide(allocator, hist_dir, now_date);
@ -231,6 +317,7 @@ pub fn run(
.then_map = &then_side.map,
.now_map = &now_side.map,
.attribution = attribution,
.projections = projections_block,
});
}
}
@ -267,7 +354,12 @@ fn printSnapNote(color: bool, requested: Date, actual: Date, label: []const u8)
///
/// `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.
/// optional and folded into the view only when set. `projections` is
/// optional when set, a compact projected-return + safe-withdrawal
/// delta block renders between the attribution and the per-symbol
/// table. Kept outside `CompareView` because CompareView is
/// renderer-agnostic and the projection data carries CLI-specific
/// computation (Monte Carlo SWR, trailing returns).
const RenderArgs = struct {
then_date: Date,
now_date: Date,
@ -277,6 +369,16 @@ const RenderArgs = struct {
then_map: *const view.HoldingMap,
now_map: *const view.HoldingMap,
attribution: ?contributions.AttributionSummary,
projections: ?ProjectionsBlock = null,
};
/// Pre-computed projections block for embedding in the compare
/// output. Caller computes (runs the projections pipeline for both
/// endpoints) and passes in; the renderer just prints. Gates the
/// perf cost of projection computation on the `--projections` flag.
const ProjectionsBlock = struct {
then: projections.KeyMetrics,
now: projections.KeyMetrics,
};
/// Build the view from two holdings maps + totals, then render.
@ -315,7 +417,7 @@ fn renderFromParts(
};
}
try renderCompare(out, color, cv);
try renderCompare(out, color, cv, args.projections);
}
// Live-portfolio side (CLI-only)
@ -403,7 +505,7 @@ const LiveSide = struct {
// the renderer-specific layout choices (leading indent, newline
// placement, two-color totals line).
fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void {
fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?ProjectionsBlock) !void {
var then_buf: [10]u8 = undefined;
var now_buf: [10]u8 = undefined;
const then_str = cv.then_date.format(&then_buf);
@ -429,6 +531,14 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void {
try renderAttributionLine(out, color, cv.liquid.delta, a);
}
// Optional projections block (opt-in via --projections/-p).
// Slots between the attribution rows and the per-symbol table
// so the "headline" numbers cluster together at the top.
if (proj) |p| {
try out.print("\n", .{});
try projections.renderKeyComparisonRows(out, color, p.then, p.now);
}
try out.print("\n", .{});
// Per-symbol table
@ -604,7 +714,7 @@ test "renderCompare: basic output includes expected elements" {
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
// Header
@ -638,7 +748,7 @@ test "renderCompare: two-snapshot mode shows real date, not 'today'" {
var buf: [1024]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-03-15") != null);
@ -663,7 +773,7 @@ test "renderCompare: 1-day diff uses singular 'day'" {
var buf: [1024]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(1 day)") != null);
@ -696,7 +806,7 @@ test "renderCompare: only added positions (no removed)" {
var buf: [1024]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2024-01-15 — hidden)") != null);
@ -717,7 +827,7 @@ test "renderCompare: negative totals delta" {
var buf: [2048]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
// Delta is signed negative; pct same
@ -746,7 +856,7 @@ test "renderCompare: attribution line when attribution is set" {
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Investment gains:") != null);
@ -774,7 +884,7 @@ test "renderCompare: no attribution line when attribution is null" {
var buf: [2048]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Investment gains:") == null);
@ -803,7 +913,7 @@ test "renderCompare: attribution handles negative gains" {
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "+$15,000.00") != null);
@ -857,7 +967,7 @@ test "renderCompare: gainer/loser summary line renders with pluralization" {
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
// Plural gainers, singular loser
@ -885,7 +995,7 @@ test "renderCompare: gainer/loser summary suppressed when no held symbols" {
var buf: [2048]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
// Neither "gainer" nor "loser" should appear the summary is
@ -941,7 +1051,7 @@ test "renderCompare: gainer/loser summary includes flat when present" {
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "1 gainer") != null);

View file

@ -300,6 +300,329 @@ pub fn run(
try out.print("\n", .{});
}
/// Key numbers extracted from a fully-built `ProjectionContext` for
/// email-header headline comparison. Bundles what `zfin compare`'s
/// attribution line needs alongside what the weekly review email's
/// "Projected Return" and "1st Year Withdrawal" rows cite.
pub const KeyMetrics = struct {
/// The "conservative" trailing-returns estimate (MIN 3Y/5Y/10Y
/// per position, weighted). Rendered under the label
/// "Projected return" matches the email's column header.
projected_return: f64,
/// Safe withdrawal amount at longest horizon × 99% confidence.
/// This is the "retirement now, 1st year withdrawal" number the
/// email cites.
swr_99: f64,
/// `swr_99 / total_value`. Rendered as a percent alongside the
/// dollar amount for comparison sanity.
swr_99_rate: f64,
};
fn extractKeyMetrics(ctx: view.ProjectionContext) KeyMetrics {
const horizons = ctx.config.getHorizons();
const longest = horizons.len - 1;
const swr = ctx.data.withdrawals[ctx.data.ci_99 * horizons.len + longest];
const rate = if (ctx.total_value > 0) swr.annual_amount / ctx.total_value else 0.0;
return .{
.projected_return = ctx.comparison.conservative_return,
.swr_99 = swr.annual_amount,
.swr_99_rate = rate,
};
}
/// Build a `ProjectionContext` against a historical snapshot date.
///
/// Caller owns `snap_bundle_out.*` on success it must outlive the
/// returned context because allocations borrow symbol strings from
/// the snapshot's backing buffer.
fn loadAsOfContext(
allocator: std.mem.Allocator,
va: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
portfolio_dir: []const u8,
events_enabled: bool,
requested_date: Date,
resolution_out: *AsOfResolution,
snap_bundle_out: *history.LoadedSnapshot,
) !view.ProjectionContext {
resolution_out.* = resolveAsOfSnapshot(va, file_path, requested_date) catch |err| return err;
const hist_dir = try history.deriveHistoryDir(va, file_path);
snap_bundle_out.* = try history.loadSnapshotAt(allocator, hist_dir, resolution_out.actual);
return try view.loadProjectionContextAsOf(
va,
portfolio_dir,
&snap_bundle_out.snap,
resolution_out.actual,
svc,
events_enabled,
);
}
/// `--vs <DATE>` entry point: compare two projections side-by-side
/// with deltas. By default `now` is the live portfolio; when
/// `as_of_now` is non-null, `now` is also a historical snapshot
/// letting the caller compare any two points in time without
/// intermediate arithmetic.
///
/// Target audience is the weekly review email's header the
/// "Projected Return" and "1st Year Withdrawal" rows with Δ columns.
/// For the full benchmark table / SWR grid / percentile bands, run
/// `zfin projections` and `zfin projections --as-of <DATE>` separately.
pub fn runCompare(
allocator: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
events_enabled: bool,
vs_date: Date,
as_of_now: ?Date,
color: bool,
out: *std.Io.Writer,
) !void {
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const va = arena_state.allocator();
const result = computeKeyComparison(allocator, va, svc, file_path, events_enabled, vs_date, as_of_now) catch |err| switch (err) {
error.NoSnapshot, error.PortfolioLoadFailed => return,
else => return err,
};
defer result.cleanup();
try out.print("\n", .{});
var then_buf: [10]u8 = undefined;
var now_buf: [10]u8 = undefined;
const then_str = result.resolution.actual.format(&then_buf);
const now_str = if (result.now_resolution) |nr| nr.actual.format(&now_buf) else "today";
const days_between = if (result.now_resolution) |nr|
nr.actual.days - result.resolution.actual.days
else
fmt.todayDate().days - result.resolution.actual.days;
try cli.printBold(out, color, "Projections comparison: {s} → {s} ({d} day{s})\n", .{
then_str,
now_str,
days_between,
if (days_between == 1) "" else "s",
});
// Snap notes for either endpoint, if applicable.
if (result.resolution.actual.days != result.resolution.requested.days) {
const diff = result.resolution.requested.days - result.resolution.actual.days;
var req_buf: [10]u8 = undefined;
try cli.printFg(out, color, cli.CLR_MUTED, "(requested {s} for then; nearest snapshot: {s}, {d} day{s} earlier)\n", .{
result.resolution.requested.format(&req_buf),
then_str,
diff,
if (diff == 1) "" else "s",
});
}
if (result.now_resolution) |nr| {
if (nr.actual.days != nr.requested.days) {
const diff = nr.requested.days - nr.actual.days;
var req_buf: [10]u8 = undefined;
try cli.printFg(out, color, cli.CLR_MUTED, "(requested {s} for now; nearest snapshot: {s}, {d} day{s} earlier)\n", .{
nr.requested.format(&req_buf),
now_str,
diff,
if (diff == 1) "" else "s",
});
}
}
try out.print("\n", .{});
try renderKeyComparisonRows(out, color, result.then, result.now);
try cli.printFg(out, color, cli.CLR_MUTED, "\nFor the full benchmark + SWR tables run `zfin projections --as-of {s}` and `zfin projections{s}`.\n", .{
then_str,
if (result.now_resolution) |_| (try std.fmt.allocPrint(va, " --as-of {s}", .{now_str})) else "",
});
try out.print("\n", .{});
}
/// Shared key-metrics comparison used by both `projections --vs` and
/// `compare --projections`. Returns `then`/`now` metrics ready for
/// rendering, plus the snapshot resolutions for header rendering.
/// Caller must invoke `cleanup()` to release retained snapshots.
///
/// When `as_of_now` is null, the "now" side is the live portfolio.
/// When set, it's loaded as a snapshot the function then retains
/// two snapshot bundles so both must be cleaned up.
pub const KeyComparisonResult = struct {
then: KeyMetrics,
now: KeyMetrics,
/// Resolution of the "then" snapshot. Always present.
resolution: AsOfResolution,
/// Resolution of the "now" snapshot. Null when now is live.
now_resolution: ?AsOfResolution,
retained_then: history.LoadedSnapshot,
retained_now: ?history.LoadedSnapshot,
retained_allocator: std.mem.Allocator,
pub fn cleanup(self: KeyComparisonResult) void {
var mut = self;
mut.retained_then.deinit(self.retained_allocator);
if (mut.retained_now) |*s| s.deinit(self.retained_allocator);
}
};
pub fn computeKeyComparison(
allocator: std.mem.Allocator,
va: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
events_enabled: bool,
vs_date: Date,
as_of_now: ?Date,
) !KeyComparisonResult {
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];
// Load "then" snapshot first. If it doesn't exist we bail before
// doing the (more expensive) "now" side.
var then_resolution: AsOfResolution = undefined;
var then_snap: history.LoadedSnapshot = undefined;
const then_ctx = try loadAsOfContext(
allocator,
va,
svc,
file_path,
portfolio_dir,
events_enabled,
vs_date,
&then_resolution,
&then_snap,
);
// Now side either another snapshot or the live portfolio.
if (as_of_now) |now_date| {
var now_resolution: AsOfResolution = undefined;
var now_snap: history.LoadedSnapshot = undefined;
const now_ctx = loadAsOfContext(
allocator,
va,
svc,
file_path,
portfolio_dir,
events_enabled,
now_date,
&now_resolution,
&now_snap,
) catch |err| {
then_snap.deinit(allocator);
return err;
};
return .{
.then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx),
.resolution = then_resolution,
.now_resolution = now_resolution,
.retained_then = then_snap,
.retained_now = now_snap,
.retained_allocator = allocator,
};
}
// Live "now" side mirrors `run()`'s live path.
var loaded = cli.loadPortfolio(allocator, file_path) orelse {
then_snap.deinit(allocator);
return error.PortfolioLoadFailed;
};
defer loaded.deinit(allocator);
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
for (loaded.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, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
then_snap.deinit(allocator);
try cli.stderrPrint("Error computing portfolio summary.\n");
return error.PortfolioLoadFailed;
},
else => {
then_snap.deinit(allocator);
return err;
},
};
defer pf_data.deinit(allocator);
const now_ctx = try view.loadProjectionContext(
va,
portfolio_dir,
pf_data.summary.allocations,
pf_data.summary.total_value,
loaded.portfolio.totalCash(),
loaded.portfolio.totalCdFaceValue(),
svc,
events_enabled,
);
return .{
.then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx),
.resolution = then_resolution,
.now_resolution = null,
.retained_then = then_snap,
.retained_now = null,
.retained_allocator = allocator,
};
}
/// Render the three comparison rows (projected return, SWR @99%, SWR
/// rate). Shared between `projections --vs` and any other caller that
/// wants to embed the same block (e.g. `compare --projections`).
pub fn renderKeyComparisonRows(
out: *std.Io.Writer,
color: bool,
then: KeyMetrics,
now: KeyMetrics,
) !void {
try renderCompareRowPct(out, color, "Projected return:", then.projected_return, now.projected_return);
try renderCompareRowMoney(out, color, "Safe withdrawal @99%:", then.swr_99, now.swr_99);
try renderCompareRowPct(out, color, " (as % of total)", then.swr_99_rate, now.swr_99_rate);
}
/// Render a "label: then → now Δ" row for percentage values.
fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
const delta = now_val - then_val;
var then_buf: [16]u8 = undefined;
var now_buf: [16]u8 = undefined;
var delta_buf: [16]u8 = undefined;
const then_str = std.fmt.bufPrint(&then_buf, "{d:.2}%", .{then_val * 100.0}) catch "?";
const now_str = std.fmt.bufPrint(&now_buf, "{d:.2}%", .{now_val * 100.0}) catch "?";
const delta_str = std.fmt.bufPrint(&delta_buf, "{s}{d:.2}%", .{ if (delta >= 0) "+" else "", delta * 100.0 }) catch "?";
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label});
try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} → {s: >10} ", .{ then_str, now_str });
try cli.printGainLoss(out, color, delta, "{s: >10}\n", .{delta_str});
}
/// Render a "label: then → now Δ" row for money values.
fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
const delta = now_val - then_val;
var then_buf: [32]u8 = undefined;
var now_buf: [32]u8 = undefined;
var delta_buf: [32]u8 = undefined;
const then_str = fmt.fmtMoneyAbs(&then_buf, then_val);
const now_str = fmt.fmtMoneyAbs(&now_buf, now_val);
const view_hist = @import("../views/history.zig");
const delta_str = view_hist.fmtSignedMoneyBuf(&delta_buf, delta);
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label});
try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} → {s: >10} ", .{ then_str, now_str });
try cli.printGainLoss(out, color, delta, "{s: >12}\n", .{delta_str});
}
/// Resolve the user's requested as-of date against the history directory.
///
/// Thin adapter over `cli.resolveSnapshotOrExplain` the shared CLI

View file

@ -86,6 +86,19 @@ const usage =
\\ shortcuts (1W, 1M, 3M, 1Q, 1Y, 3Y, 5Y), or 'live'.
\\ Auto-snaps to nearest-earlier snapshot if the
\\ exact date has no snapshot file.
\\ --vs <DATE|N[WMQY]> Compact side-by-side comparison: projected return
\\ and safe-withdrawal @99% for live vs DATE, with
\\ deltas. Combine with --as-of to compare two
\\ historical dates (--vs = then, --as-of = now).
\\
\\Compare command options:
\\ --projections Include projected return + safe-withdrawal @99%
\\ deltas between the attribution rows and the
\\ per-symbol table. Opt-in because projections cost
\\ ~1-2s per endpoint (Monte Carlo SWR search).
\\ --no-events (with --projections) Exclude life events from the
\\ underlying projection simulation. Matches the
\\ `projections --no-events` flag.
\\
\\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
@ -433,14 +446,17 @@ fn runCli() !u8 {
} else if (std.mem.eql(u8, command, "projections")) {
var events_enabled = true;
var as_of: ?zfin.Date = null;
var vs_date: ?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")) {
} else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint("Error: --as-of requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n");
try cli.stderrPrint("Error: ");
try cli.stderrPrint(a);
try cli.stderrPrint(" requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n");
return 1;
}
const value = cmd_args[i + 1];
@ -452,23 +468,40 @@ fn runCli() !u8 {
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");
try cli.stderrPrint("Error: date is in the future.\n");
return 1;
}
as_of = d;
if (std.mem.eql(u8, a, "--as-of")) {
as_of = d;
} else {
vs_date = d;
}
}
// null (= "live") is ignored leaves flag unset, same
// as not passing the flag at all.
i += 1; // consume the value
} else {
try reportUnexpectedArg("projections", a);
return 1;
}
}
if (as_of != null and vs_date == null) {
// Single-date mode: view that snapshot only.
}
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, as_of, color, out);
if (vs_date) |d| {
// Compare mode. `as_of` (if set) designates the "now"
// side otherwise now is live. `--vs` alone compares
// live against a historical date; `--vs X --as-of Y`
// compares two historical dates with Y being the later
// one.
try commands.projections.runCompare(allocator, &svc, pf.path, events_enabled, d, as_of, color, out);
} else {
try commands.projections.run(allocator, &svc, pf.path, events_enabled, as_of, color, out);
}
} else if (std.mem.eql(u8, command, "contributions")) {
var since: ?zfin.Date = null;
var until: ?zfin.Date = null;