migrate portfolio to new cli framework

This commit is contained in:
Emil Lerch 2026-05-18 16:37:36 -07:00
parent 2a89125977
commit 4cb5d1f711
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 95 additions and 16 deletions

View file

@ -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));
}

View file

@ -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);