182 lines
6.6 KiB
Zig
182 lines
6.6 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 = "etf",
|
|
.group = .symbol_lookup,
|
|
.synopsis = "Show ETF profile (holdings, sectors, expense ratio)",
|
|
.uppercase_first_arg = true,
|
|
.help =
|
|
\\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)
|
|
\\
|
|
,
|
|
.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: '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 opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
|
|
const result = svc.getEtfProfile(parsed.symbol, opts) catch |err| switch (err) {
|
|
zfin.DataError.NoApiKey => {
|
|
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(ctx.io, "Error fetching ETF profile.\n");
|
|
return;
|
|
},
|
|
};
|
|
|
|
const profile = result.data;
|
|
defer result.deinit();
|
|
|
|
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached ETF profile)\n");
|
|
|
|
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 {
|
|
try cli.printBold(out, color, "\nETF Profile: {s}\n", .{symbol});
|
|
try out.print("========================================\n", .{});
|
|
|
|
if (profile.expense_ratio) |er| {
|
|
try out.print(" Expense Ratio: {d:.2}%\n", .{er * 100.0});
|
|
}
|
|
if (profile.net_assets) |na| {
|
|
try out.print(" Net Assets: ${s}\n", .{std.mem.trimEnd(u8, &fmt.fmtLargeNum(na), &.{' '})});
|
|
}
|
|
if (profile.dividend_yield) |dy| {
|
|
try out.print(" Dividend Yield: {d:.2}%\n", .{dy * 100.0});
|
|
}
|
|
if (profile.portfolio_turnover) |pt| {
|
|
try out.print(" Portfolio Turnover: {d:.1}%\n", .{pt * 100.0});
|
|
}
|
|
if (profile.inception_date) |d| {
|
|
try out.print(" Inception Date: {f}\n", .{d});
|
|
}
|
|
if (profile.leveraged) {
|
|
try cli.printFg(out, color, cli.CLR_NEGATIVE, " Leveraged: YES\n", .{});
|
|
}
|
|
if (profile.total_holdings) |th| {
|
|
try out.print(" Total Holdings: {d}\n", .{th});
|
|
}
|
|
|
|
// Sectors
|
|
if (profile.sectors) |sectors| {
|
|
if (sectors.len > 0) {
|
|
try cli.printBold(out, color, "\n Sector Allocation:\n", .{});
|
|
for (sectors) |sec| {
|
|
var title_buf: [64]u8 = undefined;
|
|
const name = fmt.toTitleCase(&title_buf, sec.name);
|
|
try cli.printFg(out, color, cli.CLR_ACCENT, " {d:>5.1}%", .{sec.weight * 100.0});
|
|
try out.print(" {s}\n", .{name});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Top holdings
|
|
if (profile.holdings) |holdings| {
|
|
if (holdings.len > 0) {
|
|
try cli.printBold(out, color, "\n Top Holdings:\n", .{});
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" {s:>6} {s:>7} {s}\n", .{ "Symbol", "Weight", "Name" });
|
|
try out.print(" {s:->6} {s:->7} {s:->30}\n", .{ "", "", "" });
|
|
try cli.reset(out, color);
|
|
for (holdings) |h| {
|
|
if (h.symbol) |s| {
|
|
try cli.printFg(out, color, cli.CLR_ACCENT, " {s:>6}", .{s});
|
|
try out.print(" {d:>6.2}% {s}\n", .{ h.weight * 100.0, h.name });
|
|
} else {
|
|
try out.print(" {s:>6} {d:>6.2}% {s}\n", .{ "--", h.weight * 100.0, h.name });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try out.print("\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 "printProfile minimal ETF no color" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const profile: zfin.EtfProfile = .{
|
|
.symbol = "VTI",
|
|
.expense_ratio = 0.0003,
|
|
.dividend_yield = 0.015,
|
|
.total_holdings = 3500,
|
|
};
|
|
try printProfile(profile, "VTI", 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, "Expense Ratio") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "0.03%") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "3500") != null);
|
|
// No ANSI when color=false
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
|
}
|
|
|
|
test "printProfile leveraged ETF shows warning" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const profile: zfin.EtfProfile = .{
|
|
.symbol = "TQQQ",
|
|
.expense_ratio = 0.0095,
|
|
.leveraged = true,
|
|
};
|
|
try printProfile(profile, "TQQQ", false, &w);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Leveraged") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "YES") != null);
|
|
}
|