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 = "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 \\ \\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(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(ctx.io, "Error fetching data.\n"); return; }, }; defer ctx.allocator.free(result.candles); defer if (result.dividends) |d| zfin.Dividend.freeSlice(ctx.allocator, d); 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 = ctx.today.lastDayOfPriorMonth(); 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}); try out.print("{f}", .{c[0].date}); try out.print(" to ", .{}); try out.print("{f}", .{end_date}); try cli.reset(out, color); try out.print(")\nLatest close: {f}\n", .{Money.from(c[c.len - 1].close)}); const has_divs = result.asof_total != null; // -- As-of-date returns -- try cli.printBold(out, color, "\nAs-of {f}:\n", .{end_date}); try printReturnsTable(out, result.asof_price, if (has_divs) result.asof_total else null, color); // -- Month-end returns -- try cli.printBold(out, color, "\nMonth-end ({f}):\n", .{month_end}); try printReturnsTable(out, result.me_price, if (has_divs) result.me_total else null, color); if (!has_divs) { try cli.printFg(out, color, cli.CLR_MUTED, "\nSet POLYGON_API_KEY for total returns with dividend reinvestment.\n", .{}); } // -- Risk metrics -- const tr = zfin.risk.trailingRisk(c); try printRiskTable(out, tr, color); try out.print("\n", .{}); } pub fn printReturnsTable( out: *std.Io.Writer, price: zfin.performance.TrailingReturns, total: ?zfin.performance.TrailingReturns, color: bool, ) !void { const has_total = total != null; try cli.setFg(out, color, cli.CLR_MUTED); if (has_total) { try out.print("{s:>22} {s:>14} {s:>14}\n", .{ "", "Price Only", "Total Return" }); try out.print("{s:->22} {s:->14} {s:->14}\n", .{ "", "", "" }); } else { try out.print("{s:>22} {s:>14}\n", .{ "", "Price Only" }); try out.print("{s:->22} {s:->14}\n", .{ "", "" }); } try cli.reset(out, color); const periods = [_]struct { label: []const u8, years: u16 }{ .{ .label = "1-Year Return:", .years = 1 }, .{ .label = "3-Year Return:", .years = 3 }, .{ .label = "5-Year Return:", .years = 5 }, .{ .label = "10-Year Return:", .years = 10 }, }; const price_arr = [_]?zfin.performance.PerformanceResult{ price.one_year, price.three_year, price.five_year, price.ten_year, }; const total_arr: [4]?zfin.performance.PerformanceResult = if (total) |t| .{ t.one_year, t.three_year, t.five_year, t.ten_year } else .{ null, null, null, null }; for (periods, 0..) |period, i| { try out.print(" {s:<20}", .{period.label}); var price_buf: [32]u8 = undefined; var total_buf: [32]u8 = undefined; const row = fmt.fmtReturnsRow( &price_buf, &total_buf, price_arr[i], if (has_total) total_arr[i] else null, period.years > 1, ); if (price_arr[i] != null) { try cli.printGainLoss(out, color, if (row.price_positive) 1.0 else -1.0, " {s:>13}", .{row.price_str}); } else { try cli.printFg(out, color, cli.CLR_MUTED, " {s:>13}", .{row.price_str}); } if (has_total) { if (row.total_str) |ts| { try cli.printGainLoss(out, color, if (row.price_positive) 1.0 else -1.0, " {s:>13}", .{ts}); } else { try cli.printFg(out, color, cli.CLR_MUTED, " {s:>13}", .{"N/A"}); } } if (row.suffix.len > 0) { try cli.printFg(out, color, cli.CLR_MUTED, "{s}", .{row.suffix}); } try out.print("\n", .{}); } } pub fn printRiskTable(out: *std.Io.Writer, tr: zfin.risk.TrailingRisk, color: bool) !void { const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year }; // Only show if at least one period has data var any = false; for (risk_arr) |r| { if (r != null) { any = true; break; } } if (!any) return; try cli.printBold(out, color, "\nRisk Metrics (monthly returns):\n", .{}); try cli.setFg(out, color, cli.CLR_MUTED); try out.print("{s:>22} {s:>14} {s:>14} {s:>14}\n", .{ "", "Volatility", "Sharpe", "Max DD" }); try out.print("{s:->22} {s:->14} {s:->14} {s:->14}\n", .{ "", "", "", "" }); try cli.reset(out, color); const labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" }; for (0..4) |i| { try out.print(" {s:<20}", .{labels[i]}); if (risk_arr[i]) |rm| { try out.print(" {d:>12.1}%", .{rm.volatility * 100.0}); try out.print(" {d:>13.2}", .{rm.sharpe}); try cli.printFg(out, color, cli.CLR_NEGATIVE, " {d:>12.1}%", .{rm.max_drawdown * 100.0}); } else { try cli.printFg(out, color, cli.CLR_MUTED, " {s:>13} {s:>13} {s:>13}", .{ "—", "—", "—" }); } 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 "printReturnsTable price-only with no data" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const empty: zfin.performance.TrailingReturns = .{ .one_year = null, .three_year = null, .five_year = null, .ten_year = null, }; try printReturnsTable(&w, empty, null, false); const out = w.buffered(); // Should contain header and N/A for all periods try std.testing.expect(std.mem.indexOf(u8, out, "Price Only") != null); try std.testing.expect(std.mem.indexOf(u8, out, "N/A") != null); try std.testing.expect(std.mem.indexOf(u8, out, "1-Year") != null); try std.testing.expect(std.mem.indexOf(u8, out, "10-Year") != null); } test "printReturnsTable price-only no ANSI without color" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const empty: zfin.performance.TrailingReturns = .{ .one_year = null, .three_year = null, .five_year = null, .ten_year = null, }; try printReturnsTable(&w, empty, null, false); const out = w.buffered(); // No ANSI escape sequences when color=false try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); } test "printReturnsTable with total return columns" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const empty: zfin.performance.TrailingReturns = .{ .one_year = null, .three_year = null, .five_year = null, .ten_year = null, }; try printReturnsTable(&w, empty, empty, false); const out = w.buffered(); // Should contain both column headers try std.testing.expect(std.mem.indexOf(u8, out, "Price Only") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Total Return") != null); } test "printReturnsTable with actual returns" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const returns: zfin.performance.TrailingReturns = .{ .one_year = .{ .total_return = 0.15, .annualized_return = null, .from = .{ .days = 0 }, .to = .{ .days = 365 } }, .three_year = null, .five_year = null, .ten_year = null, }; try printReturnsTable(&w, returns, null, false); const out = w.buffered(); // 1-Year should show a value, not N/A // Check that the line with "1-Year" does NOT have N/A right after it // (crude check: the output should have fewer N/A occurrences than with all nulls) try std.testing.expect(std.mem.indexOf(u8, out, "1-Year") != null); // 3-year should still show N/A try std.testing.expect(std.mem.indexOf(u8, out, "ann.") != null); } test "printRiskTable: all-null returns silently (no output)" { var buf: [2048]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const empty: zfin.risk.TrailingRisk = .{}; try printRiskTable(&w, empty, false); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } test "printRiskTable: with one period populated, header + data row appear" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const tr: zfin.risk.TrailingRisk = .{ .one_year = .{ .volatility = 0.18, .sharpe = 1.25, .max_drawdown = 0.12, .sample_size = 12, }, }; try printRiskTable(&w, tr, false); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Risk Metrics") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Volatility") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Sharpe") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Max DD") != null); try std.testing.expect(std.mem.indexOf(u8, out, "1-Year") != null); // Volatility renders as "18.0%" try std.testing.expect(std.mem.indexOf(u8, out, "18.0%") != null); // Sharpe renders as "1.25" try std.testing.expect(std.mem.indexOf(u8, out, "1.25") != null); // Max DD renders as "12.0%" try std.testing.expect(std.mem.indexOf(u8, out, "12.0%") != null); } test "printRiskTable: missing periods render as em-dash placeholders" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const tr: zfin.risk.TrailingRisk = .{ .three_year = .{ .volatility = 0.20, .sharpe = 0.80, .max_drawdown = 0.25, .sample_size = 36, }, }; try printRiskTable(&w, tr, false); const out = w.buffered(); // 1-year, 5-year, 10-year all missing; em-dashes appear try std.testing.expect(std.mem.indexOf(u8, out, "—") != null); try std.testing.expect(std.mem.indexOf(u8, out, "3-Year") != null); } test "printRiskTable: no ANSI escapes when color=false" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const tr: zfin.risk.TrailingRisk = .{ .one_year = .{ .volatility = 0.15, .sharpe = 1.0, .max_drawdown = 0.10, .sample_size = 12, }, }; try printRiskTable(&w, tr, false); try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null); }