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 \\ \\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); }