190 lines
6.9 KiB
Zig
190 lines
6.9 KiB
Zig
const std = @import("std");
|
|
const zfin = @import("../root.zig");
|
|
const cli = @import("common.zig");
|
|
const framework = @import("framework.zig");
|
|
const fmt = cli.fmt;
|
|
|
|
pub const ParsedArgs = struct {
|
|
symbol: []const u8,
|
|
};
|
|
|
|
pub const meta: framework.Meta = .{
|
|
.name = "divs",
|
|
.group = .symbol_lookup,
|
|
.synopsis = "Show dividend history (with TTM yield) for a symbol",
|
|
.uppercase_first_arg = true,
|
|
.help =
|
|
\\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
|
|
\\
|
|
,
|
|
.user_errors = error{ MissingSymbol, UnexpectedArg },
|
|
};
|
|
|
|
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(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
|
|
return;
|
|
},
|
|
else => {
|
|
try cli.stderrPrint(ctx.io, "Error fetching dividend data.\n");
|
|
return;
|
|
},
|
|
};
|
|
defer result.deinit();
|
|
|
|
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(parsed.symbol)) |q| {
|
|
current_price = q.close;
|
|
} else |_| {}
|
|
|
|
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 {
|
|
try cli.printBold(out, color, "\nDividend History for {s}\n", .{symbol});
|
|
try out.print("========================================\n", .{});
|
|
|
|
if (dividends.len == 0) {
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " No dividends found.\n\n", .{});
|
|
return;
|
|
}
|
|
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print("{s:>12} {s:>10} {s:>12} {s:>6} {s:>10}\n", .{
|
|
"Ex-Date", "Amount", "Pay Date", "Freq", "Type",
|
|
});
|
|
try out.print("{s:->12} {s:->10} {s:->12} {s:->6} {s:->10}\n", .{
|
|
"", "", "", "", "",
|
|
});
|
|
try cli.reset(out, color);
|
|
|
|
const one_year_ago = as_of.subtractYears(1);
|
|
var total: f64 = 0;
|
|
var ttm: f64 = 0;
|
|
|
|
for (dividends) |div| {
|
|
try out.print("{f} {d:>10.4}", .{ div.ex_date.padLeft(12), div.amount });
|
|
if (div.pay_date) |pd| {
|
|
try out.print(" {f}", .{pd.padLeft(12)});
|
|
} else {
|
|
try out.print(" {s:>12}", .{"--"});
|
|
}
|
|
if (div.frequency) |f| {
|
|
try out.print(" {d:>6}", .{f});
|
|
} else {
|
|
try out.print(" {s:>6}", .{"--"});
|
|
}
|
|
try out.print(" {s:>10}\n", .{@tagName(div.type)});
|
|
total += div.amount;
|
|
if (!div.ex_date.lessThan(one_year_ago)) ttm += div.amount;
|
|
}
|
|
|
|
try out.print("\n{d} dividends, total: ${d:.4}\n", .{ dividends.len, total });
|
|
try cli.setFg(out, color, cli.CLR_ACCENT);
|
|
try out.print("TTM dividends: ${d:.4}", .{ttm});
|
|
if (current_price) |cp| {
|
|
if (cp > 0) {
|
|
const yield = (ttm / cp) * 100.0;
|
|
try out.print(" (yield: {d:.2}% at ${d:.2})", .{ yield, cp });
|
|
}
|
|
}
|
|
try cli.reset(out, color);
|
|
try out.print("\n\n", .{});
|
|
}
|
|
|
|
// ── 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);
|
|
const divs = [_]zfin.Dividend{
|
|
.{ .ex_date = .{ .days = 20000 }, .amount = 0.88, .type = .regular },
|
|
.{ .ex_date = .{ .days = 19900 }, .amount = 0.88, .type = .regular },
|
|
};
|
|
try display(&divs, "VTI", 250.0, zfin.Date.fromYmd(2024, 10, 1), false, &w);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "0.8800") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "2 dividends") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "TTM") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "yield") != null);
|
|
}
|
|
|
|
test "display shows empty message" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const divs = [_]zfin.Dividend{};
|
|
try display(&divs, "BRK.A", null, zfin.Date.fromYmd(2024, 10, 1), false, &w);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "No dividends found") != null);
|
|
}
|
|
|
|
test "display without price omits yield" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const divs = [_]zfin.Dividend{
|
|
.{ .ex_date = .{ .days = 20000 }, .amount = 1.50, .type = .regular },
|
|
};
|
|
try display(&divs, "T", null, zfin.Date.fromYmd(2024, 10, 1), false, &w);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "yield") == null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "1 dividends") != null);
|
|
}
|
|
|
|
test "display no ANSI without color" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const divs = [_]zfin.Dividend{
|
|
.{ .ex_date = .{ .days = 20000 }, .amount = 0.50, .type = .regular },
|
|
};
|
|
try display(&divs, "SPY", 500.0, zfin.Date.fromYmd(2024, 10, 1), false, &w);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
|
}
|