diff --git a/src/commands/enrich.zig b/src/commands/enrich.zig index 6402b94..1e94b90 100644 --- a/src/commands/enrich.zig +++ b/src/commands/enrich.zig @@ -1,9 +1,63 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = @import("../format.zig"); const isCusipLike = @import("../models/portfolio.zig").isCusipLike; +pub const ParsedArgs = struct { + /// Either a symbol (e.g. "AAPL") or a path to a portfolio file + /// (e.g. "portfolio.srf"). Distinguished by suffix / path-separator + /// heuristic at run time so the user can pass either form. + arg: []const u8, +}; + +pub const meta = struct { + pub const name: []const u8 = "enrich"; + pub const group: framework.Group = .hygiene; + pub const synopsis: []const u8 = "Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)"; + pub const help: []const u8 = + \\Usage: zfin enrich + \\ + \\Bootstrap a `metadata.srf` classification file from Alpha + \\Vantage's OVERVIEW endpoint. Two modes: + \\ + \\ - File mode (path or `*.srf` suffix): enrich every stock + \\ symbol in the portfolio. Output is a complete SRF file + \\ written to stdout — redirect into metadata.srf and + \\ edit by hand for accuracy. + \\ - Symbol mode (anything else): enrich a single symbol and + \\ emit one appendable SRF line. Useful for adding to an + \\ existing metadata.srf without rerunning the whole file. + \\ + \\Caveats: Alpha Vantage's free tier is 25 requests/day. The + \\OVERVIEW data is US-domicile-biased — international ETFs + \\classify as `geo::US`. Always review the output before + \\saving as `metadata.srf`. Requires ALPHAVANTAGE_API_KEY. + \\ + \\Examples: + \\ zfin enrich portfolio.srf > metadata.srf # whole portfolio + \\ zfin enrich AAPL >> metadata.srf # single symbol append + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + if (cmd_args.len < 1) { + try cli.stderrPrint(ctx.io, "Error: 'enrich' requires a portfolio file path or symbol\n"); + return error.MissingArg; + } + if (cmd_args.len > 1) { + try cli.stderrPrint(ctx.io, "Error: 'enrich' takes a single argument (file path or symbol)\n"); + return error.UnexpectedArg; + } + return .{ .arg = cmd_args[0] }; +} + const OverviewMeta = struct { sector: []const u8, geo: []const u8, @@ -38,20 +92,21 @@ fn deriveMetadata(overview: zfin.CompanyOverview, sector_buf: []u8) OverviewMeta /// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each, /// and outputs a metadata SRF file to stdout. /// If the argument looks like a symbol (no path separators, no .srf extension), enrich just that symbol. -pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, arg: []const u8, as_of: zfin.Date, out: *std.Io.Writer) !void { +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; // Determine if arg is a symbol or a file path - const is_file = std.mem.endsWith(u8, arg, ".srf") or - std.mem.indexOfScalar(u8, arg, '/') != null or - std.mem.indexOfScalar(u8, arg, '.') != null; + const is_file = std.mem.endsWith(u8, parsed.arg, ".srf") or + std.mem.indexOfScalar(u8, parsed.arg, '/') != null or + std.mem.indexOfScalar(u8, parsed.arg, '.') != null; if (!is_file) { // Single symbol mode: enrich one symbol, output appendable SRF (no header) - try enrichSymbol(io, allocator, svc, arg, out); + try enrichSymbol(ctx.io, ctx.allocator, svc, parsed.arg, ctx.out); return; } // Portfolio file mode: enrich all symbols - try enrichPortfolio(io, allocator, svc, arg, as_of, out); + try enrichPortfolio(ctx.io, ctx.allocator, svc, parsed.arg, ctx.today, ctx.out); } /// Enrich a single symbol and output appendable SRF lines to stdout. @@ -82,13 +137,13 @@ fn enrichSymbol(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService } var sector_buf: [64]u8 = undefined; - const meta = deriveMetadata(overview, §or_buf); + const derived = deriveMetadata(overview, §or_buf); if (overview.name) |name| { try out.print("# {s}\n", .{name}); } try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n", .{ - sym, meta.sector, meta.geo, meta.asset_class, + sym, derived.sector, derived.geo, derived.asset_class, }); } @@ -174,14 +229,14 @@ fn enrichPortfolio(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataServ } var sector_buf: [64]u8 = undefined; - const meta = deriveMetadata(overview, §or_buf); + const derived = deriveMetadata(overview, §or_buf); // Comment with the name for readability if (overview.name) |name| { try out.print("# {s}\n", .{name}); } try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n\n", .{ - sym, meta.sector, meta.geo, meta.asset_class, + sym, derived.sector, derived.geo, derived.asset_class, }); success += 1; } @@ -193,3 +248,35 @@ fn enrichPortfolio(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataServ }); try out.print("# Review and edit this file, then save as metadata.srf\n", .{}); } + +// ── Tests ──────────────────────────────────────────────────── + +test "parseArgs: accepts a symbol argument" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"AAPL"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("AAPL", parsed.arg); +} + +test "parseArgs: accepts a file path argument" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"portfolio.srf"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("portfolio.srf", parsed.arg); +} + +test "parseArgs: missing arg errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + try std.testing.expectError(error.MissingArg, parseArgs(&ctx, &args)); +} + +test "parseArgs: extra args error" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "AAPL", "extra" }; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} diff --git a/src/main.zig b/src/main.zig index d8a0955..b7eb772 100644 --- a/src/main.zig +++ b/src/main.zig @@ -22,6 +22,7 @@ const command_modules = .{ .etf = @import("commands/etf.zig"), // Data hygiene + .enrich = @import("commands/enrich.zig"), .lookup = @import("commands/lookup.zig"), // Infrastructure @@ -508,12 +509,6 @@ fn runCli(init: std.process.Init) !u8 { 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, "enrich")) { - if (cmd_args.len < 1) { - try cli.stderrPrint(io, "Error: 'enrich' requires a portfolio file path or symbol\n"); - return 1; - } - try commands.enrich.run(io, allocator, &svc, cmd_args[0], today, 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);