diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index e7c248e..2e46526 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -33,6 +33,7 @@ const std = @import("std"); const srf = @import("srf"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = @import("../format.zig"); const atomic = @import("../atomic.zig"); const version = @import("../version.zig"); @@ -56,66 +57,111 @@ pub const SnapshotError = error{ WriteFailed, }; -// ── Entry point ────────────────────────────────────────────── +pub const ParsedArgs = struct { + force: bool = false, + dry_run: bool = false, + out_override: ?[]const u8 = null, + as_of_override: ?Date = null, +}; -/// Run the snapshot command. -/// -/// `args` is the slice after `zfin snapshot`. Accepted flags: -/// --force overwrite existing snapshot for as_of_date -/// --out override output path (skips the default derivation) -/// --dry-run compute + print, do not write -/// -/// Exit semantics: -/// 0 on success (including duplicate-skip) -/// non-zero on any error -pub fn run( - io: std.Io, - allocator: std.mem.Allocator, - svc: *zfin.DataService, - portfolio_path: []const u8, - args: []const []const u8, - now_s: i64, - color: bool, - out: *std.Io.Writer, -) !void { - // Parse flags. - var force = false; - var dry_run = false; - var out_override: ?[]const u8 = null; - var as_of_override: ?Date = null; +pub const meta = struct { + pub const name: []const u8 = "snapshot"; + pub const group: framework.Group = .timeseries; + pub const synopsis: []const u8 = "Write a daily portfolio snapshot to history/"; + pub const help: []const u8 = + \\Usage: zfin snapshot [opts] + \\ + \\Compute a portfolio snapshot for today (or a historical date with + \\`--as-of`) and write it as a discriminated SRF file under + \\`history/-portfolio.srf`. The output records start with + \\`kind::` so readers can demux on + \\that field. + \\ + \\Default mode: refresh the candle cache for held symbols, derive + \\`as_of_date` from the mode of cached candle dates, look up + \\close-on-or-before for each lot, and write atomically. + \\ + \\Options: + \\ --force overwrite existing snapshot for as_of_date + \\ --dry-run compute + print to stdout, do not write + \\ --out override output path + \\ --as-of write a snapshot for a historical date + \\ (uses git to recover portfolio state and + \\ candle cache for pricing). DATE accepts + \\ YYYY-MM-DD or relative shortcuts + \\ (1W/1M/1Q/1Y). + \\ + \\If `history/-portfolio.srf` already exists and `--force` + \\wasn't passed, the run skips with a stderr message. + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + var parsed: ParsedArgs = .{}; var i: usize = 0; - while (i < args.len) : (i += 1) { - const a = args[i]; + while (i < cmd_args.len) : (i += 1) { + const a = cmd_args[i]; if (std.mem.eql(u8, a, "--force")) { - force = true; + parsed.force = true; } else if (std.mem.eql(u8, a, "--dry-run")) { - dry_run = true; + parsed.dry_run = true; } else if (std.mem.eql(u8, a, "--out")) { i += 1; - if (i >= args.len) { - try cli.stderrPrint(io, "Error: --out requires a path argument\n"); + if (i >= cmd_args.len) { + try cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n"); return error.UnexpectedArg; } - out_override = args[i]; + parsed.out_override = cmd_args[i]; } else if (std.mem.eql(u8, a, "--as-of")) { i += 1; - if (i >= args.len) { - try cli.stderrPrint(io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n"); + if (i >= cmd_args.len) { + try cli.stderrPrint(ctx.io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n"); return error.UnexpectedArg; } // Reference date for resolving relative forms in `--as-of` // (e.g. "1W" → 7 days before this anchor). - const flag_anchor = Date.fromEpoch(now_s); - as_of_override = cli.parseRequiredDateOrStderr(io, args[i], flag_anchor, "--as-of") catch |err| switch (err) { + const flag_anchor = Date.fromEpoch(ctx.now_s); + parsed.as_of_override = cli.parseRequiredDateOrStderr(ctx.io, cmd_args[i], flag_anchor, "--as-of") catch |err| switch (err) { error.InvalidDate => return error.UnexpectedArg, }; } else { - try cli.stderrPrint(io, "Error: unknown argument to 'snapshot': "); - try cli.stderrPrint(io, a); - try cli.stderrPrint(io, "\n"); + try cli.stderrPrint(ctx.io, "Error: unknown argument to 'snapshot': "); + try cli.stderrPrint(ctx.io, a); + try cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } } + return parsed; +} + +// ── Entry point ────────────────────────────────────────────── + +/// Run the snapshot command. +/// +/// Exit semantics: +/// 0 on success (including duplicate-skip) +/// non-zero on any error +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; + const io = ctx.io; + const allocator = ctx.allocator; + const out = ctx.out; + const color = ctx.color; + const now_s = ctx.now_s; + + const force = parsed.force; + const dry_run = parsed.dry_run; + const out_override = parsed.out_override; + const as_of_override = parsed.as_of_override; + + const pf = ctx.resolvePortfolioPath(); + defer pf.deinit(allocator); + const portfolio_path = pf.path; // Load portfolio bytes. In normal (no --as-of) mode this is the // current working-copy of portfolio.srf. With --as-of, we first try @@ -906,6 +952,70 @@ pub fn renderSnapshot(allocator: std.mem.Allocator, snap: Snapshot) ![]const u8 const testing = std.testing; +test "parseArgs: defaults" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.now_s = 0; + const args = [_][]const u8{}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expect(!parsed.force); + try std.testing.expect(!parsed.dry_run); + try std.testing.expect(parsed.out_override == null); + try std.testing.expect(parsed.as_of_override == null); +} + +test "parseArgs: --force --dry-run" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.now_s = 0; + const args = [_][]const u8{ "--force", "--dry-run" }; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expect(parsed.force); + try std.testing.expect(parsed.dry_run); +} + +test "parseArgs: --out captures path" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.now_s = 0; + const args = [_][]const u8{ "--out", "/tmp/snap.srf" }; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("/tmp/snap.srf", parsed.out_override.?); +} + +test "parseArgs: --out without value errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.now_s = 0; + const args = [_][]const u8{"--out"}; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + +test "parseArgs: --as-of with explicit date" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.now_s = 0; + const args = [_][]const u8{ "--as-of", "2026-01-15" }; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expect(parsed.as_of_override.?.eql(Date.fromYmd(2026, 1, 15))); +} + +test "parseArgs: --as-of without value errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.now_s = 0; + const args = [_][]const u8{"--as-of"}; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + +test "parseArgs: unknown arg errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.now_s = 0; + const args = [_][]const u8{"--bogus"}; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + test "deriveSnapshotPath: standard layout" { // Build the input portfolio path and expected output from // path-joined components so the test runs on both POSIX and Windows. diff --git a/src/main.zig b/src/main.zig index a9b8e23..ff75155 100644 --- a/src/main.zig +++ b/src/main.zig @@ -27,6 +27,9 @@ const command_modules = .{ .analysis = @import("commands/analysis.zig"), .milestones = @import("commands/milestones.zig"), + // Time-series & journaling + .snapshot = @import("commands/snapshot.zig"), + // Data hygiene .enrich = @import("commands/enrich.zig"), .lookup = @import("commands/lookup.zig"), @@ -681,13 +684,6 @@ fn runCli(init: std.process.Init) !u8 { const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); try commands.contributions.run(io, allocator, &svc, pf.path, before_final, after_final, today, color, out); - } else if (std.mem.eql(u8, command, "snapshot")) { - const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); - defer if (pf.resolved) |r| r.deinit(allocator); - commands.snapshot.run(io, allocator, &svc, pf.path, cmd_args, now_s, color, out) catch |err| switch (err) { - error.UnexpectedArg, error.PortfolioEmpty, error.WriteFailed => return 1, - else => return err, - }; } else if (std.mem.eql(u8, command, "compare")) { const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator);