From 4cb5d1f711b7e1462063375556fb65a051cd2cc4 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 18 May 2026 16:37:36 -0700 Subject: [PATCH] migrate portfolio to new cli framework --- src/commands/portfolio.zig | 95 +++++++++++++++++++++++++++++++++++++- src/main.zig | 16 +------ 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index cdea142..e97636e 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -1,11 +1,79 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = cli.fmt; const Money = @import("../Money.zig"); const views = @import("../views/portfolio_sections.zig"); -pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { +pub const ParsedArgs = struct { + /// `--refresh`: invalidate the candle cache before loading prices, + /// forcing a re-fetch from providers (server sync where configured, + /// otherwise per-symbol provider calls). + force_refresh: bool = false, +}; + +pub const meta = struct { + pub const name: []const u8 = "portfolio"; + pub const group: framework.Group = .portfolio; + pub const synopsis: []const u8 = "Load and analyze the portfolio (positions + valuations + watchlist)"; + pub const help: []const u8 = + \\Usage: zfin portfolio [--refresh] + \\ + \\Load `portfolio.srf` (cwd → ZFIN_HOME), refresh per-symbol + \\prices in parallel (server sync where ZFIN_SERVER is set, + \\else providers), and print the position table + valuations + \\+ historical-snapshot mini-tables. The watchlist (if + \\`watchlist.srf` exists) is appended to the price-load step + \\so its quotes show alongside. + \\ + \\Options: + \\ --refresh Force a re-fetch of every symbol's candles, + \\ bypassing the per-symbol TTL freshness check. + \\ Useful when you suspect cached data is wrong + \\ or after rotating providers. + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + var parsed: ParsedArgs = .{}; + for (cmd_args) |a| { + if (std.mem.eql(u8, a, "--refresh")) { + parsed.force_refresh = true; + } else { + try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': "); + try cli.stderrPrint(ctx.io, a); + try cli.stderrPrint(ctx.io, "\n"); + return error.UnexpectedArg; + } + } + return parsed; +} + +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 as_of = ctx.today; + + const pf = ctx.resolvePortfolioPath(); + defer pf.deinit(allocator); + const file_path = pf.path; + + const wl = ctx.resolveWatchlistPath(); + defer wl.deinit(allocator); + const watchlist_path: ?[]const u8 = + if (ctx.globals.watchlist_path != null or wl.resolved != null) wl.path else null; + + const force_refresh = parsed.force_refresh; + // Load portfolio from SRF file var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return; defer loaded.deinit(allocator); @@ -803,3 +871,28 @@ test "display empty watchlist not shown" { // Watchlist header should NOT appear when there are no watch symbols try testing.expect(std.mem.indexOf(u8, out, "Watchlist") == null); } + +// ── parseArgs tests ──────────────────────────────────────────── + +test "parseArgs: no args produces force_refresh=false" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expect(!parsed.force_refresh); +} + +test "parseArgs: --refresh sets force_refresh" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"--refresh"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expect(parsed.force_refresh); +} + +test "parseArgs: unexpected args error" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"unexpected"}; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} diff --git a/src/main.zig b/src/main.zig index a061d97..bfcb552 100644 --- a/src/main.zig +++ b/src/main.zig @@ -23,6 +23,7 @@ const command_modules = .{ .etf = @import("commands/etf.zig"), // Portfolio analysis + .portfolio = @import("commands/portfolio.zig"), .milestones = @import("commands/milestones.zig"), // Data hygiene @@ -475,21 +476,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). - var force_refresh = false; - for (cmd_args) |a| { - if (std.mem.eql(u8, a, "--refresh")) { - force_refresh = true; - } else { - try reportUnexpectedArg(io, "portfolio", a); - return 1; - } - } - const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); - defer if (pf.resolved) |r| r.deinit(allocator); - const wl = resolveUserPath(io, allocator, config, globals.watchlist_path, zfin.Config.default_watchlist_filename); - defer if (wl.resolved) |r| r.deinit(allocator); - const wl_path: ?[]const u8 = if (globals.watchlist_path != null or wl.resolved != null) wl.path else null; - try commands.portfolio.run(io, allocator, &svc, pf.path, wl_path, force_refresh, today, color, out); } else if (std.mem.eql(u8, command, "audit")) { const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator);