diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 2b47f8d..3e97dc0 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -11,6 +11,7 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = cli.fmt; const Date = zfin.Date; const Money = @import("../Money.zig"); @@ -30,6 +31,235 @@ const shiller = @import("../data/shiller.zig"); const stock_benchmark = "SPY"; const bond_benchmark = "AGG"; +/// Tagged-union args for the four projection sub-modes. Mutually- +/// exclusive flag combos (--convergence with --vs, --real with +/// non-backtest, etc.) are rejected at parse time so each variant +/// here is a self-contained mode. +pub const ParsedArgs = union(enum) { + /// Default: percentile bands view. `as_of` selects the snapshot + /// (or today for live). `overlay_actuals` plots realized + /// trajectory on top. + bands: BandsArgs, + /// `--vs `: side-by-side compare of two projections. + compare: CompareArgs, + /// `--convergence`: plot the spreadsheet's predicted retirement + /// date over time. No knobs. + convergence, + /// `--return-backtest [--real]`: plot expected_return vs realized + /// forward-CAGR. + return_backtest: struct { real: bool }, +}; + +pub const BandsArgs = struct { + events_enabled: bool = true, + /// `null` means live (today). Non-null = historical snapshot. + as_of: ?Date = null, + overlay_actuals: bool = false, +}; + +pub const CompareArgs = struct { + events_enabled: bool = true, + vs_date: Date, + /// "Now" side. Null = today (live); non-null = the `--as-of` + /// date the user paired with `--vs`. + as_of: ?Date = null, +}; + +pub const meta = struct { + pub const name: []const u8 = "projections"; + pub const group: framework.Group = .portfolio; + pub const synopsis: []const u8 = "Retirement projections, benchmark comparison, percentile bands"; + pub const help: []const u8 = + \\Usage: zfin projections [opts] + \\ + \\Default mode: percentile-bands view of the portfolio's + \\projected value over the configured horizon (`projections.srf`), + \\plus benchmark comparison (SPY/AGG) and safe-withdrawal + \\dollars at multiple horizons / confidence levels. + \\ + \\Three alternate sub-modes (mutually exclusive): + \\ --vs Side-by-side compare with a + \\ historical snapshot's projection. + \\ --convergence Plot the model's predicted + \\ retirement date over time as data + \\ accumulated. + \\ --return-backtest Plot expected_return claim over + \\ time alongside realized forward + \\ CAGR. Pair with `--real` for + \\ CPI-adjusted dollars. + \\ + \\Options: + \\ --no-events Exclude life events from the + \\ simulation (baseline view). + \\ --as-of Compute against a historical + \\ snapshot. Auto-snaps to the + \\ nearest-earlier snapshot. + \\ --overlay-actuals Plot realized portfolio trajectory + \\ from --as-of through today on top + \\ of the percentile bands. Requires + \\ --as-of. Ignored under --vs. + \\ --vs (see above) + \\ --convergence (see above) + \\ --return-backtest (see above) + \\ --real With --return-backtest, render in + \\ CPI-adjusted dollars. + \\ + \\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y). + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + const io = ctx.io; + const today = ctx.today; + + var events_enabled = true; + var as_of: ?Date = null; + var vs_date: ?Date = null; + var overlay_actuals = false; + var convergence = false; + var return_backtest = false; + var real_mode = false; + + 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, "--overlay-actuals")) { + overlay_actuals = true; + } else if (std.mem.eql(u8, a, "--convergence")) { + convergence = true; + } else if (std.mem.eql(u8, a, "--return-backtest")) { + return_backtest = true; + } else if (std.mem.eql(u8, a, "--real")) { + real_mode = true; + } 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(io, "Error: "); + try cli.stderrPrint(io, a); + try cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n"); + return error.MissingFlagValue; + } + const value = cmd_args[i + 1]; + const parsed_date = cli.parseAsOfDate(value, today) catch |err| { + var buf: [256]u8 = undefined; + const msg = cli.fmtAsOfParseError(&buf, value, err); + try cli.stderrPrint(io, msg); + try cli.stderrPrint(io, "\n"); + return error.InvalidFlagValue; + }; + if (parsed_date) |d| { + if (d.days > today.days) { + try cli.stderrPrint(io, "Error: date is in the future.\n"); + return error.InvalidFlagValue; + } + 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; + } else { + try cli.stderrPrint(io, "Error: unexpected argument to 'projections': "); + try cli.stderrPrint(io, a); + try cli.stderrPrint(io, "\n"); + return error.UnexpectedArg; + } + } + + // Mutually-exclusive view flags. Forecast-evaluation flags + // (`--convergence`, `--return-backtest`) replace the default + // bands view entirely; combining them with each other, + // `--vs`, or `--overlay-actuals` is rejected. + if (convergence and return_backtest) { + try cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n"); + return error.MutuallyExclusive; + } + if ((convergence or return_backtest) and vs_date != null) { + try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n"); + return error.MutuallyExclusive; + } + if ((convergence or return_backtest) and overlay_actuals) { + try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n"); + return error.MutuallyExclusive; + } + if (real_mode and !return_backtest) { + try cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n"); + return error.MutuallyExclusive; + } + + if (convergence) return ParsedArgs{ .convergence = {} }; + if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } }; + if (vs_date) |d| { + return ParsedArgs{ .compare = .{ + .events_enabled = events_enabled, + .vs_date = d, + .as_of = as_of, + } }; + } + return ParsedArgs{ .bands = .{ + .events_enabled = events_enabled, + .as_of = as_of, + .overlay_actuals = overlay_actuals, + } }; +} + +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const io = ctx.io; + const allocator = ctx.allocator; + const out = ctx.out; + const color = ctx.color; + const today = ctx.today; + + const pf = ctx.resolvePortfolioPath(); + defer pf.deinit(allocator); + const file_path = pf.path; + + switch (parsed) { + .convergence => try runConvergence(io, allocator, file_path, color, out), + .return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, color, out), + .compare => |args| { + const svc = ctx.svc orelse return error.MissingDataService; + try runCompare( + io, + allocator, + svc, + file_path, + args.events_enabled, + args.vs_date, + args.as_of orelse today, + args.as_of != null, + color, + out, + ); + }, + .bands => |args| { + const svc = ctx.svc orelse return error.MissingDataService; + try runBands( + io, + allocator, + svc, + file_path, + args.events_enabled, + args.as_of orelse today, + args.as_of != null, + today, + args.overlay_actuals, + color, + out, + ); + }, + } +} + /// 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 @@ -50,7 +280,7 @@ const AsOfResolution = struct { liquid: f64 = 0, }; -/// Run projections. +/// Run percentile-bands projection (the default mode). /// /// `as_of` is the reference date for ages, horizons, and snapshot /// windows. `from_snapshot` selects the data source: @@ -58,7 +288,7 @@ const AsOfResolution = struct { /// today as `as_of`. /// - `true`: historical mode. Load the snapshot at-or-before /// `as_of` from the history dir. -pub fn run( +pub fn runBands( io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, @@ -1106,14 +1336,149 @@ fn renderEarliestBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, } // ── 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; + +fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs { + var ctx: framework.RunCtx = .{ + .io = std.testing.io, + .allocator = std.testing.allocator, + .gpa = std.testing.allocator, + .environ_map = undefined, + .config = .{ .cache_dir = "" }, + .svc = null, + .globals = .{}, + .today = today, + .now_s = 0, + .color = false, + .out = undefined, + }; + return parseArgs(&ctx, args); +} + +test "parseArgs: empty → bands variant with defaults" { + const today = Date.fromYmd(2026, 5, 9); + const parsed = try parseArgsForTest(today, &.{}); + switch (parsed) { + .bands => |b| { + try testing.expect(b.events_enabled); + try testing.expect(b.as_of == null); + try testing.expect(!b.overlay_actuals); + }, + else => try testing.expect(false), + } +} + +test "parseArgs: --as-of populates bands.as_of" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--as-of", "2026-04-01" }; + const parsed = try parseArgsForTest(today, &args); + switch (parsed) { + .bands => |b| try testing.expect(b.as_of.?.eql(Date.fromYmd(2026, 4, 1))), + else => try testing.expect(false), + } +} + +test "parseArgs: --no-events disables events on bands" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{"--no-events"}; + const parsed = try parseArgsForTest(today, &args); + switch (parsed) { + .bands => |b| try testing.expect(!b.events_enabled), + else => try testing.expect(false), + } +} + +test "parseArgs: --vs produces compare variant" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--vs", "2026-04-01" }; + const parsed = try parseArgsForTest(today, &args); + switch (parsed) { + .compare => |c| try testing.expect(c.vs_date.eql(Date.fromYmd(2026, 4, 1))), + else => try testing.expect(false), + } +} + +test "parseArgs: --vs + --as-of carries both into compare variant" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--vs", "2026-03-01", "--as-of", "2026-04-01" }; + const parsed = try parseArgsForTest(today, &args); + switch (parsed) { + .compare => |c| { + try testing.expect(c.vs_date.eql(Date.fromYmd(2026, 3, 1))); + try testing.expect(c.as_of.?.eql(Date.fromYmd(2026, 4, 1))); + }, + else => try testing.expect(false), + } +} + +test "parseArgs: --convergence produces convergence variant" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{"--convergence"}; + const parsed = try parseArgsForTest(today, &args); + try testing.expectEqual(std.meta.Tag(ParsedArgs).convergence, std.meta.activeTag(parsed)); +} + +test "parseArgs: --return-backtest produces return_backtest variant" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{"--return-backtest"}; + const parsed = try parseArgsForTest(today, &args); + switch (parsed) { + .return_backtest => |rb| try testing.expect(!rb.real), + else => try testing.expect(false), + } +} + +test "parseArgs: --return-backtest --real" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--return-backtest", "--real" }; + const parsed = try parseArgsForTest(today, &args); + switch (parsed) { + .return_backtest => |rb| try testing.expect(rb.real), + else => try testing.expect(false), + } +} + +test "parseArgs: --convergence + --return-backtest mutually exclusive" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--convergence", "--return-backtest" }; + try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args)); +} + +test "parseArgs: --convergence + --vs rejected" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--convergence", "--vs", "2026-04-01" }; + try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args)); +} + +test "parseArgs: --real without --return-backtest rejected" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{"--real"}; + try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args)); +} + +test "parseArgs: future --as-of rejected" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--as-of", "2027-01-01" }; + try testing.expectError(error.InvalidFlagValue, parseArgsForTest(today, &args)); +} + +test "parseArgs: unknown flag errors" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{"--bogus"}; + try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args)); +} + +test "parseArgs: --overlay-actuals carries into bands" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--as-of", "2026-04-01", "--overlay-actuals" }; + const parsed = try parseArgsForTest(today, &args); + switch (parsed) { + .bands => |b| try testing.expect(b.overlay_actuals), + else => try testing.expect(false), + } +} + const snapshot_model = @import("../models/snapshot.zig"); const snapshot = @import("snapshot.zig"); @@ -1280,7 +1645,7 @@ test "run: as_of with no snapshots returns without error (stderr-only)" { var stream = std.Io.Writer.fixed(&buf); const d = Date.fromYmd(2026, 3, 13); - try run(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream); + try runBands(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream); // No body output because the resolution failed — the stderr // message is swallowed by `cli.stderrPrint` and doesn't land in @@ -1312,7 +1677,7 @@ test "run: as_of with matching snapshot produces body output" { var buf: [32_768]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try run(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream); + try runBands(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream); const out = stream.buffered(); // Header should call out the as-of date explicitly. @@ -1343,7 +1708,7 @@ test "run: as_of auto-snap surfaces muted 'nearest' note" { var stream = std.Io.Writer.fixed(&buf); const requested = Date.fromYmd(2026, 3, 13); - try run(io, testing.allocator, &svc, pf, false, requested, true, requested, false, false, &stream); + try runBands(io, testing.allocator, &svc, pf, false, requested, true, requested, false, false, &stream); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null); diff --git a/src/main.zig b/src/main.zig index b0be489..036654a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -25,6 +25,7 @@ const command_modules = .{ // Portfolio analysis .portfolio = @import("commands/portfolio.zig"), .analysis = @import("commands/analysis.zig"), + .projections = @import("commands/projections.zig"), .milestones = @import("commands/milestones.zig"), // Time-series & journaling @@ -33,6 +34,7 @@ const command_modules = .{ .contributions = @import("commands/contributions.zig"), // Data hygiene + .audit = @import("commands/audit.zig"), .enrich = @import("commands/enrich.zig"), .lookup = @import("commands/lookup.zig"), @@ -482,105 +484,6 @@ fn runCli(init: std.process.Init) !u8 { if (std.mem.eql(u8, command, "portfolio")) { // Parse --refresh flag; reject any other token (including old // positional FILE, which is now a global -p). - } 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 overlay_actuals = false; - var convergence = false; - var return_backtest = false; - var real_mode = false; - 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, "--overlay-actuals")) { - overlay_actuals = true; - } else if (std.mem.eql(u8, a, "--convergence")) { - convergence = true; - } else if (std.mem.eql(u8, a, "--return-backtest")) { - return_backtest = true; - } else if (std.mem.eql(u8, a, "--real")) { - real_mode = true; - } 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(io, "Error: "); - try cli.stderrPrint(io, a); - try cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n"); - return 1; - } - const value = cmd_args[i + 1]; - const parsed = cli.parseAsOfDate(value, today) catch |err| { - var buf: [256]u8 = undefined; - const msg = cli.fmtAsOfParseError(&buf, value, err); - try cli.stderrPrint(io, msg); - try cli.stderrPrint(io, "\n"); - return 1; - }; - if (parsed) |d| { - if (d.days > today.days) { - try cli.stderrPrint(io, "Error: date is in the future.\n"); - return 1; - } - 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(io, "projections", a); - return 1; - } - } - - // Mutually-exclusive view flags. Forecast-evaluation flags - // (`--convergence`, `--return-backtest`) replace the default - // bands view entirely; combining them with each other, - // `--vs`, or `--overlay-actuals` is rejected. - if (convergence and return_backtest) { - try cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n"); - return 1; - } - if ((convergence or return_backtest) and vs_date != null) { - try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n"); - return 1; - } - if ((convergence or return_backtest) and overlay_actuals) { - try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n"); - return 1; - } - if (real_mode and !return_backtest) { - try cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n"); - return 1; - } - - if (as_of != null and vs_date == null) { - // Single-date mode: view that snapshot only. - } - const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); - defer if (pf.resolved) |r| r.deinit(allocator); - if (convergence) { - try commands.projections.runConvergence(io, allocator, pf.path, color, out); - } else if (return_backtest) { - try commands.projections.runReturnBacktest(io, allocator, pf.path, real_mode, color, out); - } else 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. - if (overlay_actuals) { - try cli.stderrPrint(io, "Note: --overlay-actuals is ignored in --vs compare mode.\n"); - } - try commands.projections.runCompare(io, allocator, &svc, pf.path, events_enabled, d, as_of orelse today, as_of != null, color, out); - } else { - try commands.projections.run(io, allocator, &svc, pf.path, events_enabled, as_of orelse today, as_of != null, today, overlay_actuals, color, out); - } } else { try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n"); return 1;