migrate simple commands to new cli framework
This commit is contained in:
parent
86106d291c
commit
2dcae073b0
10 changed files with 811 additions and 124 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
135
src/main.zig
135
src/main.zig
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue