add projections comparison to compare and projections commands
This commit is contained in:
parent
6e0861c5dd
commit
1839bce49b
3 changed files with 494 additions and 28 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
45
src/main.zig
45
src/main.zig
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue