zfin/src/commands/etf.zig

143 lines
5.2 KiB
Zig

const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getEtfProfile(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
return;
},
else => {
try cli.stderrPrint("Error fetching ETF profile.\n");
return;
},
};
const profile = result.data;
defer {
if (profile.holdings) |h| {
for (h) |holding| {
if (holding.symbol) |s| allocator.free(s);
allocator.free(holding.name);
}
allocator.free(h);
}
if (profile.sectors) |s| {
for (s) |sec| allocator.free(sec.sector);
allocator.free(s);
}
}
if (result.source == .cached) try cli.stderrPrint("(using cached ETF profile)\n");
try printProfile(profile, symbol, color, out);
}
pub fn printProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
try cli.setBold(out, color);
try out.print("\nETF Profile: {s}\n", .{symbol});
try cli.reset(out, color);
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.trimRight(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| {
var db: [10]u8 = undefined;
try out.print(" Inception Date: {s}\n", .{d.format(&db)});
}
if (profile.leveraged) {
try cli.setFg(out, color, cli.CLR_NEGATIVE);
try out.print(" Leveraged: YES\n", .{});
try cli.reset(out, color);
}
if (profile.total_holdings) |th| {
try out.print(" Total Holdings: {d}\n", .{th});
}
// Sectors
if (profile.sectors) |sectors| {
if (sectors.len > 0) {
try cli.setBold(out, color);
try out.print("\n Sector Allocation:\n", .{});
try cli.reset(out, color);
for (sectors) |sec| {
try cli.setFg(out, color, cli.CLR_ACCENT);
try out.print(" {d:>5.1}%", .{sec.weight * 100.0});
try cli.reset(out, color);
try out.print(" {s}\n", .{sec.sector});
}
}
}
// Top holdings
if (profile.holdings) |holdings| {
if (holdings.len > 0) {
try cli.setBold(out, color);
try out.print("\n Top Holdings:\n", .{});
try cli.reset(out, color);
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.setFg(out, color, cli.CLR_ACCENT);
try out.print(" {s:>6}", .{s});
try cli.reset(out, color);
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 "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);
}