migrate analysis to new cli framework

This commit is contained in:
Emil Lerch 2026-05-18 16:40:11 -07:00
parent 4cb5d1f711
commit 682ebd10c0
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 73 additions and 17 deletions

View file

@ -1,11 +1,56 @@
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");
pub const ParsedArgs = struct {};
pub const meta = struct {
pub const name: []const u8 = "analysis";
pub const group: framework.Group = .portfolio;
pub const synopsis: []const u8 = "Show portfolio breakdowns by asset class, sector, geo, account, tax type";
pub const help: []const u8 =
\\Usage: zfin analysis
\\
\\Show portfolio analysis: equities/fixed-income split, plus
\\block-bar breakdowns by asset class, sector, geographic
\\region, account, and tax type. Reads classifications from
\\`metadata.srf` and account tax types from `accounts.srf`
\\(both in the same directory as the portfolio file).
\\
\\Run `zfin enrich <portfolio.srf> > metadata.srf` to bootstrap
\\classifications, then edit by hand.
\\
;
};
comptime {
framework.validateCommandModule(@This());
}
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len > 0) {
try cli.stderrPrint(ctx.io, "Error: 'analysis' takes no arguments\n");
return error.UnexpectedArg;
}
return .{};
}
/// CLI `analysis` command: show portfolio analysis breakdowns.
pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
pub fn run(ctx: *framework.RunCtx, _: 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;
var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return;
defer loaded.deinit(allocator);
@ -13,16 +58,20 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, fil
const positions = loaded.positions;
const syms = loaded.syms;
// Build prices from cache
// Refresh per-symbol prices via the parallel loader so analysis
// works on TTL-fresh data by default. Previously this read
// `getCachedCandles` directly, which silently used stale data
// after long weekends or when the cache hadn't been refreshed.
// The loader emits a stderr summary line ("Loaded N symbols
// (X cached, Y server, Z provider)").
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
for (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);
}
if (syms.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, false, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
}
@ -150,6 +199,20 @@ pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.B
// Tests
test "parseArgs: no args produces empty ParsedArgs" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
_ = try parseArgs(&ctx, &args);
}
test "parseArgs: any positional is rejected" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"unexpected"};
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "printBreakdownSection single item no color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -24,6 +24,7 @@ const command_modules = .{
// Portfolio analysis
.portfolio = @import("commands/portfolio.zig"),
.analysis = @import("commands/analysis.zig"),
.milestones = @import("commands/milestones.zig"),
// Data hygiene
@ -480,14 +481,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.audit.run(io, allocator, &svc, pf.path, cmd_args, today, now_s, color, out);
} else if (std.mem.eql(u8, command, "analysis")) {
for (cmd_args) |a| {
try reportUnexpectedArg(io, "analysis", 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);
try commands.analysis.run(io, allocator, &svc, pf.path, today, color, out);
} else if (std.mem.eql(u8, command, "projections")) {
var events_enabled = true;
var as_of: ?zfin.Date = null;