diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index 78dd007..82c5896 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -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 > 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); diff --git a/src/main.zig b/src/main.zig index bfcb552..a9b8e23 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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;