migrate simple commands to new cli framework

This commit is contained in:
Emil Lerch 2026-05-17 20:45:28 -07:00
parent 86106d291c
commit 2dcae073b0
Signed by: lobo
GPG key ID: A7B62D657EF764F8
10 changed files with 811 additions and 124 deletions

View file

@ -1,30 +1,71 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
const result = svc.getDividends(symbol) catch |err| switch (err) {
pub const ParsedArgs = struct {
symbol: []const u8,
};
pub const meta = struct {
pub const name: []const u8 = "divs";
pub const group: framework.Group = .symbol_lookup;
pub const synopsis: []const u8 = "Show dividend history (with TTM yield) for a symbol";
pub const uppercase_first_arg: bool = true;
pub const help: []const u8 =
\\Usage: zfin divs <SYMBOL>
\\
\\Show the dividend history for a symbol from Polygon.io. Cached
\\for 14 days. The TTM (trailing-twelve-month) total + yield are
\\computed against the current Yahoo quote when available.
\\
\\Examples:
\\ zfin divs VTI # quarterly distributions + yield
\\ zfin divs T # historical AT&T dividends
\\
;
};
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: 'divs' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'divs' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const result = svc.getDividends(parsed.symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
return;
},
else => {
try cli.stderrPrint(io, "Error fetching dividend data.\n");
try cli.stderrPrint(ctx.io, "Error fetching dividend data.\n");
return;
},
};
defer result.deinit();
if (result.source == .cached) try cli.stderrPrint(io, "(using cached dividend data)\n");
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached dividend data)\n");
// Fetch current price for yield calculation via DataService
var current_price: ?f64 = null;
if (svc.getQuote(symbol)) |q| {
if (svc.getQuote(parsed.symbol)) |q| {
current_price = q.close;
} else |_| {}
try display(result.data, symbol, current_price, as_of, color, out);
try display(result.data, parsed.symbol, current_price, ctx.today, ctx.color, ctx.out);
}
pub fn display(dividends: []const zfin.Dividend, symbol: []const u8, current_price: ?f64, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
@ -81,6 +122,28 @@ pub fn display(dividends: []const zfin.Dividend, symbol: []const u8, current_pri
// Tests
test "parseArgs: accepts a single symbol" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"VTI"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("VTI", parsed.symbol);
}
test "parseArgs: missing symbol errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args));
}
test "parseArgs: extra args error" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "VTI", "extra" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "display shows dividend data with yield" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -1,16 +1,63 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getEarnings(symbol) catch |err| switch (err) {
pub const ParsedArgs = struct {
symbol: []const u8,
};
pub const meta = struct {
pub const name: []const u8 = "earnings";
pub const group: framework.Group = .symbol_lookup;
pub const synopsis: []const u8 = "Show earnings history (with EPS surprise) and upcoming events";
pub const uppercase_first_arg: bool = true;
pub const help: []const u8 =
\\Usage: zfin earnings <SYMBOL>
\\
\\Show the earnings history (estimate vs. actual + surprise %)
\\and any scheduled future events for a symbol from Financial
\\Modeling Prep. Cached for 30 days; the cache is also smart-
\\refreshed when a past event is missing its `actual` field
\\(catches "results just released" cases without waiting for
\\TTL expiry).
\\
\\Output is sorted newest-first; pipe through `| tail` for
\\oldest-first.
\\
\\Examples:
\\ zfin earnings NVDA
\\ zfin earnings AAPL | head -8 # last two years of quarters
\\
;
};
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: 'earnings' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'earnings' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const result = svc.getEarnings(parsed.symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n");
try cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n");
return;
},
else => {
try cli.stderrPrint(io, "Error fetching earnings data.\n");
try cli.stderrPrint(ctx.io, "Error fetching earnings data.\n");
return;
},
};
@ -28,9 +75,9 @@ pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, color: bool,
}.f);
}
if (result.source == .cached) try cli.stderrPrint(io, "(using cached earnings data)\n");
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached earnings data)\n");
try display(result.data, symbol, color, out);
try display(result.data, parsed.symbol, ctx.color, ctx.out);
}
pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
@ -71,6 +118,28 @@ pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bo
// Tests
test "parseArgs: accepts a single symbol" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"NVDA"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("NVDA", parsed.symbol);
}
test "parseArgs: missing symbol errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args));
}
test "parseArgs: extra args error" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "NVDA", "extra" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "display shows earnings with beat" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -1,16 +1,58 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getEtfProfile(symbol) catch |err| switch (err) {
pub const ParsedArgs = struct {
symbol: []const u8,
};
pub const meta = struct {
pub const name: []const u8 = "etf";
pub const group: framework.Group = .symbol_lookup;
pub const synopsis: []const u8 = "Show ETF profile (holdings, sectors, expense ratio)";
pub const uppercase_first_arg: bool = true;
pub const help: []const u8 =
\\Usage: zfin etf <SYMBOL>
\\
\\Show the ETF profile (expense ratio, AUM, dividend yield,
\\sector allocation, top holdings) for a fund symbol from
\\Alpha Vantage. Cached for 30 days. Leveraged funds are
\\flagged in red.
\\
\\Examples:
\\ zfin etf VTI # broad market index
\\ zfin etf TQQQ # leveraged (warning surfaced)
\\
;
};
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: 'etf' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'etf' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const result = svc.getEtfProfile(parsed.symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
try cli.stderrPrint(ctx.io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
return;
},
else => {
try cli.stderrPrint(io, "Error fetching ETF profile.\n");
try cli.stderrPrint(ctx.io, "Error fetching ETF profile.\n");
return;
},
};
@ -18,9 +60,9 @@ pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, color: bool,
const profile = result.data;
defer result.deinit();
if (result.source == .cached) try cli.stderrPrint(io, "(using cached ETF profile)\n");
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached ETF profile)\n");
try printProfile(profile, symbol, color, out);
try printProfile(profile, parsed.symbol, ctx.color, ctx.out);
}
pub fn printProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
@ -86,6 +128,28 @@ pub fn printProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool, o
// Tests
test "parseArgs: accepts a single symbol" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"VTI"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("VTI", parsed.symbol);
}
test "parseArgs: missing symbol errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args));
}
test "parseArgs: extra args error" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "VTI", "extra" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "printProfile minimal ETF no color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -130,7 +130,7 @@ pub const RunCtx = struct {
/// Process environment. Used by `interactive` to thread into
/// the TUI's Config refresh, and by anything that needs to
/// inspect env vars at command time.
environ_map: *const std.process.EnvMap,
environ_map: *const std.process.Environ.Map,
config: zfin.Config,
/// Null for commands that don't need provider access (`version`,
/// `cache`). All other commands must error gracefully if they
@ -260,6 +260,21 @@ pub fn validateCommandModule(comptime Module: type) void {
"pub const help: []const u8 = \"multi-line text for `zfin <cmd> --help`\";",
);
// Optional: `uppercase_first_arg`. Symbol-taking commands
// (perf, quote, divs, ...) set this to `true` so the
// dispatcher uppercases `cmd_args[0]` before calling
// `parseArgs`. When omitted, defaults to false.
if (@hasDecl(meta_decl, "uppercase_first_arg")) {
validator.expectDeclWithType(
"Command module",
mod_name,
meta_decl,
"uppercase_first_arg",
bool,
"pub const uppercase_first_arg: bool = true; // (optional, defaults to false)",
);
}
// parseArgs
validator.expectFnInferredError(
"Command module",
@ -286,6 +301,41 @@ pub fn validateCommandModule(comptime Module: type) void {
// Help rendering
/// Whether a command module opts into having its first positional
/// argument uppercased before `parseArgs` runs. Symbol-taking
/// commands set `meta.uppercase_first_arg = true` so users can type
/// `zfin perf aapl` and have it normalize to `AAPL`. Honors the
/// "skip when the arg is a flag" rule (i.e. starts with `-`).
pub fn uppercasesFirstArg(comptime Module: type) bool {
if (!@hasDecl(Module.meta, "uppercase_first_arg")) return false;
return Module.meta.uppercase_first_arg;
}
/// If `cmd_args[0]` exists, is non-empty, and isn't a flag, return
/// a copy with the first byte-string uppercased. Otherwise return
/// `cmd_args` unchanged. The new slice is owned by `allocator`
/// (typically `RunCtx.allocator`, the per-invocation arena).
///
/// Mirrors the existing pre-dispatch normalization in
/// `main.zig:runCli` so migrated commands behave identically to
/// the legacy if-else chain.
pub fn normalizeFirstArg(
allocator: std.mem.Allocator,
cmd_args: []const []const u8,
) ![]const []const u8 {
if (cmd_args.len == 0) return cmd_args;
const first = cmd_args[0];
if (first.len == 0 or first[0] == '-') return cmd_args;
const upper = try allocator.dupe(u8, first);
for (upper) |*c| c.* = std.ascii.toUpper(c.*);
const owned = try allocator.alloc([]const u8, cmd_args.len);
owned[0] = upper;
for (cmd_args[1..], 1..) |a, i| owned[i] = a;
return owned;
}
/// Print a single command's help text. Called by main.zig when the
/// user invokes `zfin <cmd> --help` or `zfin <cmd> -h`. The help
/// text comes from the module's `meta.help` field verbatim no
@ -371,3 +421,59 @@ test "printCommandHelp: appends newline when meta.help lacks one" {
try printCommandHelp(&w, NoNewline);
try testing.expectEqualStrings("no trailing newline\n", w.buffered());
}
test "uppercasesFirstArg: defaults to false when meta omits the field" {
try testing.expect(!uppercasesFirstArg(ProbeModule));
}
test "uppercasesFirstArg: honors meta.uppercase_first_arg when present" {
const M = struct {
pub const ParsedArgs = struct {};
pub const meta = struct {
pub const name: []const u8 = "m";
pub const group: Group = .symbol_lookup;
pub const synopsis: []const u8 = "m";
pub const help: []const u8 = "m";
pub const uppercase_first_arg: bool = true;
};
pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs {
return .{};
}
pub fn run(_: *RunCtx, _: ParsedArgs) !void {}
};
comptime validateCommandModule(M);
try testing.expect(uppercasesFirstArg(M));
}
test "normalizeFirstArg: empty args returns slice unchanged" {
const empty: []const []const u8 = &.{};
const out = try normalizeFirstArg(testing.allocator, empty);
try testing.expectEqual(@as(usize, 0), out.len);
}
test "normalizeFirstArg: lowercase symbol becomes uppercase" {
const args = [_][]const u8{ "aapl", "extra" };
const out = try normalizeFirstArg(testing.allocator, &args);
// Save out[0] before freeing the slice once `out` is freed,
// indexing it is use-after-free.
const upper = out[0];
defer testing.allocator.free(out);
defer testing.allocator.free(upper);
try testing.expectEqualStrings("AAPL", out[0]);
try testing.expectEqualStrings("extra", out[1]);
}
test "normalizeFirstArg: leading flag is left untouched" {
const args = [_][]const u8{ "--since", "1W" };
const out = try normalizeFirstArg(testing.allocator, &args);
// Returned the original slice unchanged no allocation to free.
try testing.expectEqual(@as(usize, 2), out.len);
try testing.expectEqualStrings("--since", out[0]);
try testing.expectEqualStrings("1W", out[1]);
}
test "normalizeFirstArg: empty first arg is left untouched" {
const args = [_][]const u8{ "", "AAPL" };
const out = try normalizeFirstArg(testing.allocator, &args);
try testing.expectEqualStrings("", out[0]);
}

View file

@ -1,18 +1,66 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const isCusipLike = @import("../models/portfolio.zig").isCusipLike;
pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool, out: *std.Io.Writer) !void {
if (!isCusipLike(cusip)) {
try cli.printFg(out, color, cli.CLR_MUTED, "Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip});
pub const ParsedArgs = struct {
cusip: []const u8,
};
pub const meta = struct {
pub const name: []const u8 = "lookup";
pub const group: framework.Group = .hygiene;
pub const synopsis: []const u8 = "Look up a CUSIP to ticker via OpenFIGI";
pub const uppercase_first_arg: bool = true;
pub const help: []const u8 =
\\Usage: zfin lookup <CUSIP>
\\
\\Look up a CUSIP (9-char alphanumeric security identifier) to
\\its ticker via the OpenFIGI API. Successful results are cached
\\indefinitely in `cusip_tickers.srf`. OPENFIGI_API_KEY raises
\\the rate limit but the unauthenticated tier works for low
\\volume.
\\
\\Mutual funds frequently have no OpenFIGI coverage; the
\\command surfaces that and suggests a manual portfolio entry.
\\
\\Examples:
\\ zfin lookup 037833100 # → AAPL
\\
;
};
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: 'lookup' requires a CUSIP argument\n");
return error.MissingCusip;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'lookup' takes a single CUSIP argument\n");
return error.UnexpectedArg;
}
return .{ .cusip = cmd_args[0] };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const out = ctx.out;
const color = ctx.color;
const allocator = ctx.allocator;
if (!isCusipLike(parsed.cusip)) {
try cli.printFg(out, color, cli.CLR_MUTED, "Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{parsed.cusip});
}
try cli.stderrPrint(io, "Looking up via OpenFIGI...\n");
try cli.stderrPrint(ctx.io, "Looking up via OpenFIGI...\n");
// Try full batch lookup for richer output
const results = svc.lookupCusips(&.{cusip}) catch {
try cli.stderrPrint(io, "Error: OpenFIGI request failed (network error)\n");
const results = svc.lookupCusips(&.{parsed.cusip}) catch {
try cli.stderrPrint(ctx.io, "Error: OpenFIGI request failed (network error)\n");
return;
};
defer {
@ -25,15 +73,15 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, cus
}
if (results.len == 0 or !results[0].found) {
try out.print("No result from OpenFIGI for '{s}'\n", .{cusip});
try out.print("No result from OpenFIGI for '{s}'\n", .{parsed.cusip});
return;
}
try display(results[0], cusip, color, out);
try display(results[0], parsed.cusip, color, out);
// Also cache it
if (results[0].ticker) |ticker| {
svc.cacheCusipTicker(cusip, ticker);
svc.cacheCusipTicker(parsed.cusip, ticker);
}
}
@ -63,6 +111,28 @@ pub fn display(result: zfin.CusipResult, cusip: []const u8, color: bool, out: *s
// Tests
test "parseArgs: accepts a single CUSIP" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"037833100"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("037833100", parsed.cusip);
}
test "parseArgs: missing CUSIP errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
try std.testing.expectError(error.MissingCusip, parseArgs(&ctx, &args));
}
test "parseArgs: extra args error" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "037833100", "extra" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "display shows ticker mapping" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -1,31 +1,96 @@
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 fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void {
const result = svc.getOptions(symbol) catch |err| switch (err) {
pub const ParsedArgs = struct {
symbol: []const u8,
/// `--ntm <N>`: show ±N strikes near the money on the auto-expanded
/// expiration. Default 8.
ntm: usize = 8,
};
pub const meta = struct {
pub const name: []const u8 = "options";
pub const group: framework.Group = .symbol_lookup;
pub const synopsis: []const u8 = "Show options chain (all expirations) for a symbol";
pub const uppercase_first_arg: bool = true;
pub const help: []const u8 =
\\Usage: zfin options <SYMBOL> [--ntm <N>]
\\
\\Show the options chain (all expirations) for a symbol from
\\CBOE. Cached for 1 hour. The nearest monthly expiration is
\\auto-expanded with ±N strikes near the money; other
\\expirations are listed collapsed.
\\
\\Options:
\\ --ntm <N> Show ±N strikes near the money (default: 8)
\\
\\Examples:
\\ zfin options SPY
\\ zfin options AAPL --ntm 12
\\
;
};
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: 'options' requires a symbol argument\n");
return error.MissingSymbol;
}
var parsed: ParsedArgs = .{ .symbol = cmd_args[0] };
var i: usize = 1;
while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--ntm")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --ntm requires a value\n");
return error.MissingFlagValue;
}
parsed.ntm = std.fmt.parseInt(usize, cmd_args[i + 1], 10) catch {
try cli.stderrPrint(ctx.io, "Error: --ntm value must be a non-negative integer\n");
return error.InvalidFlagValue;
};
i += 1;
} else {
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'options': ");
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 result = svc.getOptions(parsed.symbol) catch |err| switch (err) {
zfin.DataError.FetchFailed => {
try cli.stderrPrint(io, "Error fetching options data from CBOE.\n");
try cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n");
return;
},
else => {
try cli.stderrPrint(io, "Error loading options data.\n");
try cli.stderrPrint(ctx.io, "Error loading options data.\n");
return;
},
};
const ch = result.data;
defer result.deinit();
if (result.source == .cached) try cli.stderrPrint(io, "(using cached options data)\n");
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached options data)\n");
if (ch.len == 0) {
try cli.stderrPrint(io, "No options data found.\n");
try cli.stderrPrint(ctx.io, "No options data found.\n");
return;
}
try display(out, ch, symbol, ntm, color);
try display(ctx.out, ch, parsed.symbol, parsed.ntm, ctx.color);
}
pub fn display(out: *std.Io.Writer, chains: []const zfin.OptionsChain, symbol: []const u8, ntm: usize, color: bool) !void {
@ -117,6 +182,51 @@ pub fn printSection(
// Tests
test "parseArgs: accepts a symbol with default ntm" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"SPY"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("SPY", parsed.symbol);
try std.testing.expectEqual(@as(usize, 8), parsed.ntm);
}
test "parseArgs: --ntm overrides default" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "SPY", "--ntm", "12" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqual(@as(usize, 12), parsed.ntm);
}
test "parseArgs: missing symbol errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args));
}
test "parseArgs: --ntm without value errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "SPY", "--ntm" };
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}
test "parseArgs: --ntm with non-numeric value errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "SPY", "--ntm", "abc" };
try std.testing.expectError(error.InvalidFlagValue, parseArgs(&ctx, &args));
}
test "parseArgs: unknown flag errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "SPY", "--bogus" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "printSection shows header and contracts" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -1,30 +1,80 @@
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 fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
const result = svc.getTrailingReturns(symbol) catch |err| switch (err) {
pub const ParsedArgs = struct {
symbol: []const u8,
};
pub const meta = struct {
pub const name: []const u8 = "perf";
pub const group: framework.Group = .symbol_lookup;
pub const synopsis: []const u8 = "Show 1y/3y/5y/10y trailing returns (Morningstar-style)";
pub const uppercase_first_arg: bool = true;
pub const help: []const u8 =
\\Usage: zfin perf <SYMBOL>
\\
\\Show Morningstar-style trailing returns for a symbol — 1Y,
\\3Y, 5Y, 10Y price-only and total-return CAGR plus risk
\\metrics (Sharpe, max drawdown, vol). Total returns require
\\POLYGON_API_KEY (for dividend history); price-only
\\returns work without it.
\\
\\Two return tables are produced:
\\ - As-of: returns through the latest cached close.
\\ - Month-end: returns through the most recent calendar
\\ month-end (matches how mutual funds quote their stats).
\\
\\Examples:
\\ zfin perf VTI # total-market index, 30+ years of data
\\ zfin perf NVDA # individual stock
\\
;
};
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: 'perf' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'perf' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const result = svc.getTrailingReturns(parsed.symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n");
try cli.stderrPrint(ctx.io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n");
return;
},
else => {
try cli.stderrPrint(io, "Error fetching data.\n");
try cli.stderrPrint(ctx.io, "Error fetching data.\n");
return;
},
};
defer allocator.free(result.candles);
defer if (result.dividends) |d| zfin.Dividend.freeSlice(allocator, d);
defer ctx.allocator.free(result.candles);
defer if (result.dividends) |d| zfin.Dividend.freeSlice(ctx.allocator, d);
if (result.source == .cached) try cli.stderrPrint(io, "(using cached data)\n");
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached data)\n");
const c = result.candles;
const end_date = c[c.len - 1].date;
const month_end = as_of.lastDayOfPriorMonth();
const month_end = ctx.today.lastDayOfPriorMonth();
try cli.printBold(out, color, "\nTrailing Returns for {s}\n", .{symbol});
const out = ctx.out;
const color = ctx.color;
try cli.printBold(out, color, "\nTrailing Returns for {s}\n", .{parsed.symbol});
try out.print("========================================\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("Data points: {d} (", .{c.len});
@ -160,6 +210,28 @@ pub fn printRiskTable(out: *std.Io.Writer, tr: zfin.risk.TrailingRisk, color: bo
// Tests
test "parseArgs: accepts a single symbol" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"VTI"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("VTI", parsed.symbol);
}
test "parseArgs: missing symbol errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args));
}
test "parseArgs: extra args error" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "VTI", "extra" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "printReturnsTable price-only with no data" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -1,9 +1,41 @@
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 {
symbol: []const u8,
};
pub const meta = struct {
pub const name: []const u8 = "quote";
pub const group: framework.Group = .symbol_lookup;
pub const synopsis: []const u8 = "Show latest quote with chart and 20-day history";
pub const uppercase_first_arg: bool = true;
pub const help: []const u8 =
\\Usage: zfin quote <SYMBOL>
\\
\\Show the latest real-time quote for a symbol (Yahoo / TwelveData)
\\plus a braille price chart of the last 60 candles and a table
\\of the last 20 trading days.
\\
\\If real-time fetch fails, falls back to the cached close. The
\\Yahoo path is free and unauthenticated; TwelveData requires
\\TWELVEDATA_API_KEY.
\\
\\Examples:
\\ zfin quote AAPL
\\ zfin quote spy # symbols are case-insensitive
\\
;
};
comptime {
framework.validateCommandModule(@This());
}
/// Quote data extracted from the real-time API (or synthesized from candles).
pub const QuoteData = struct {
price: f64,
@ -15,15 +47,28 @@ pub const QuoteData = struct {
date: zfin.Date,
};
pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
try cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
// Fetch candle data for chart and history
const candle_result = svc.getCandles(symbol) catch |err| switch (err) {
const candle_result = svc.getCandles(parsed.symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(io, "Error: No API key configured for candle data.\n");
try cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n");
return;
},
else => {
try cli.stderrPrint(io, "Error fetching candle data.\n");
try cli.stderrPrint(ctx.io, "Error fetching candle data.\n");
return;
},
};
@ -32,7 +77,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, sym
// Fetch real-time quote via DataService
var quote: ?QuoteData = null;
if (svc.getQuote(symbol)) |q| {
if (svc.getQuote(parsed.symbol)) |q| {
quote = .{
.price = q.close,
.open = q.open,
@ -40,11 +85,11 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, sym
.low = q.low,
.volume = q.volume,
.prev_close = q.previous_close,
.date = if (candles.len > 0) candles[candles.len - 1].date else as_of,
.date = if (candles.len > 0) candles[candles.len - 1].date else ctx.today,
};
} else |_| {}
try display(allocator, candles, quote, symbol, as_of, color, out);
try display(ctx.allocator, candles, quote, parsed.symbol, ctx.today, ctx.color, ctx.out);
}
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
@ -122,6 +167,28 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
// Tests
test "parseArgs: accepts a single symbol" {
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.symbol);
}
test "parseArgs: missing symbol errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
try std.testing.expectError(error.MissingSymbol, 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));
}
test "display with candles only" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -1,24 +1,65 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getSplits(symbol) catch |err| switch (err) {
pub const ParsedArgs = struct {
symbol: []const u8,
};
pub const meta = struct {
pub const name: []const u8 = "splits";
pub const group: framework.Group = .symbol_lookup;
pub const synopsis: []const u8 = "Show split history for a symbol";
pub const uppercase_first_arg: bool = true;
pub const help: []const u8 =
\\Usage: zfin splits <SYMBOL>
\\
\\Show the split history for a symbol from Polygon.io. Cached
\\for 14 days; subsequent calls within that window serve the
\\cached data without an API call.
\\
\\Examples:
\\ zfin splits AAPL # 4:1 (2020-08-31), 7:1 (2014-06-09)
\\ zfin splits NVDA # 10:1 (2024-06-10), 4:1 (2021-07-20), ...
\\
;
};
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: 'splits' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'splits' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const result = svc.getSplits(parsed.symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
return;
},
else => {
try cli.stderrPrint(io, "Error fetching split data.\n");
try cli.stderrPrint(ctx.io, "Error fetching split data.\n");
return;
},
};
defer result.deinit();
if (result.source == .cached) try cli.stderrPrint(io, "(using cached split data)\n");
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached split data)\n");
try display(result.data, symbol, color, out);
try display(result.data, parsed.symbol, ctx.color, ctx.out);
}
pub fn display(splits: []const zfin.Split, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
@ -43,6 +84,28 @@ pub fn display(splits: []const zfin.Split, symbol: []const u8, color: bool, out:
// Tests
test "parseArgs: accepts a single symbol" {
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.symbol);
}
test "parseArgs: missing symbol errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
try std.testing.expectError(error.MissingSymbol, 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));
}
test "display shows split data" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -370,6 +370,45 @@ fn runCli(init: std.process.Init) !u8 {
var svc = zfin.DataService.init(io, allocator, config);
defer svc.deinit();
// Framework dispatch
//
// Comptime walk over `command_modules`. Each registered command
// owns its own flag parsing (`parseArgs`) and execution (`run`)
// both take `*RunCtx`. As more commands migrate the legacy
// if-else chain below shrinks; once empty (commit 17) it goes
// away entirely along with the per-command symbol-uppercasing
// hack.
inline for (std.meta.fields(@TypeOf(command_modules))) |f| {
if (std.mem.eql(u8, command, f.name)) {
const Module = @field(command_modules, f.name);
var ctx: cmd_framework.RunCtx = .{
.io = io,
.allocator = allocator,
.gpa = gpa_alloc,
.environ_map = init.environ_map,
.config = config,
.svc = &svc,
.globals = .{
.no_color = globals.no_color,
.portfolio_path = globals.portfolio_path,
.watchlist_path = globals.watchlist_path,
},
.today = today,
.now_s = now_s,
.color = color,
.out = out,
};
const dispatched_args = if (comptime cmd_framework.uppercasesFirstArg(Module))
try cmd_framework.normalizeFirstArg(allocator, cmd_args)
else
cmd_args;
const parsed = Module.parseArgs(&ctx, dispatched_args) catch return 1;
try Module.run(&ctx, parsed);
try out.flush();
return 0;
}
}
// Normalize symbol argument (cmd_args[0]) to uppercase for commands
// that take a symbol. Skip for commands whose first arg is a subcommand
// or operand of a different kind.
@ -406,19 +445,7 @@ fn runCli(init: std.process.Init) !u8 {
cmd_args = owned;
}
if (std.mem.eql(u8, command, "perf")) {
if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'perf' requires a symbol argument\n");
return 1;
}
try commands.perf.run(io, allocator, &svc, cmd_args[0], today, color, out);
} else if (std.mem.eql(u8, command, "quote")) {
if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'quote' requires a symbol argument\n");
return 1;
}
try commands.quote.run(io, allocator, &svc, cmd_args[0], today, color, out);
} else if (std.mem.eql(u8, command, "history")) {
if (std.mem.eql(u8, command, "history")) {
// Two modes in one command:
// zfin history <SYMBOL> candle history for a symbol (legacy)
// zfin history [flags] portfolio timeline from history/*.srf
@ -441,45 +468,6 @@ fn runCli(init: std.process.Init) !u8 {
else => return err,
};
}
} else if (std.mem.eql(u8, command, "divs")) {
if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'divs' requires a symbol argument\n");
return 1;
}
try commands.divs.run(io, &svc, cmd_args[0], today, color, out);
} else if (std.mem.eql(u8, command, "splits")) {
if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'splits' requires a symbol argument\n");
return 1;
}
try commands.splits.run(io, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "options")) {
if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'options' requires a symbol argument\n");
return 1;
}
// Parse --ntm flag.
var ntm: usize = 8;
var ai: usize = 1;
while (ai < cmd_args.len) : (ai += 1) {
if (std.mem.eql(u8, cmd_args[ai], "--ntm") and ai + 1 < cmd_args.len) {
ai += 1;
ntm = std.fmt.parseInt(usize, cmd_args[ai], 10) catch 8;
}
}
try commands.options.run(io, &svc, cmd_args[0], ntm, color, out);
} else if (std.mem.eql(u8, command, "earnings")) {
if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'earnings' requires a symbol argument\n");
return 1;
}
try commands.earnings.run(io, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "etf")) {
if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'etf' requires a symbol argument\n");
return 1;
}
try commands.etf.run(io, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "portfolio")) {
// Parse --refresh flag; reject any other token (including old
// positional FILE, which is now a global -p).
@ -498,12 +486,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, "lookup")) {
if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'lookup' requires a CUSIP argument\n");
return 1;
}
try commands.lookup.run(io, allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "cache")) {
if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'cache' requires a subcommand (stats, clear)\n");
@ -816,17 +798,38 @@ fn globalOffender(args: []const []const u8) ?[]const u8 {
}
// Command modules
const cmd_framework = @import("commands/framework.zig");
/// Comptime registry of CLI commands that have migrated to the
/// framework contract. The dispatcher in `runCli` walks this with
/// `inline for`; the field name is the user-facing command name
/// and the value is the imported module struct. Each registered
/// module satisfies `framework.validateCommandModule` (enforced at
/// the registry's comptime block below).
///
/// During the migration window, this registry coexists with the
/// legacy if-else chain in `runCli`. Once every command is
/// registered, the legacy chain goes away (commit 17).
const command_modules = .{
.divs = @import("commands/divs.zig"),
.earnings = @import("commands/earnings.zig"),
.etf = @import("commands/etf.zig"),
.lookup = @import("commands/lookup.zig"),
.options = @import("commands/options.zig"),
.perf = @import("commands/perf.zig"),
.quote = @import("commands/quote.zig"),
.splits = @import("commands/splits.zig"),
};
comptime {
for (std.meta.fields(@TypeOf(command_modules))) |f| {
cmd_framework.validateCommandModule(@field(command_modules, f.name));
}
}
const commands = struct {
const perf = @import("commands/perf.zig");
const quote = @import("commands/quote.zig");
const history = @import("commands/history.zig");
const divs = @import("commands/divs.zig");
const splits = @import("commands/splits.zig");
const options = @import("commands/options.zig");
const earnings = @import("commands/earnings.zig");
const etf = @import("commands/etf.zig");
const portfolio = @import("commands/portfolio.zig");
const lookup = @import("commands/lookup.zig");
const cache = @import("commands/cache.zig");
const analysis = @import("commands/analysis.zig");
const audit = @import("commands/audit.zig");