zfin/src/commands/earnings.zig

176 lines
6.3 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 = "earnings",
.group = .symbol_lookup,
.synopsis = "Show earnings history (with EPS surprise) and upcoming events",
.uppercase_first_arg = true,
.help =
\\Usage: zfin earnings <SYMBOL>
\\
\\Show the earnings history (estimate vs. actual + surprise %)
\\and any scheduled future events for a symbol from Financial
\\Modeling Prep. Cached for 30 days; the cache is also smart-
\\refreshed when a past event is missing its `actual` field
\\(catches "results just released" cases without waiting for
\\TTL expiry).
\\
\\Output is sorted newest-first; pipe through `| tail` for
\\oldest-first.
\\
\\Examples:
\\ zfin earnings NVDA
\\ zfin earnings AAPL | head -8 # last two years of quarters
\\
,
};
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: 'earnings' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'earnings' 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.getEarnings(parsed.symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n");
return;
},
else => {
try cli.stderrPrint(ctx.io, "Error fetching earnings data.\n");
return;
},
};
defer result.deinit();
// Sort newest-first — the first row is the most recent quarter, which
// is the dominant query. Matches `git log` / `ls -lt` / `last` defaults
// and the TUI. `| head -N` gives you the N most recent quarters;
// `| tail` still works if you want oldest-first.
if (result.data.len > 1) {
std.mem.sort(zfin.EarningsEvent, result.data, {}, struct {
fn f(_: void, a: zfin.EarningsEvent, b: zfin.EarningsEvent) bool {
return a.date.days > b.date.days;
}
}.f);
}
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached earnings data)\n");
try display(result.data, parsed.symbol, ctx.color, ctx.out);
}
pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
try cli.printBold(out, color, "\nEarnings History for {s}\n", .{symbol});
try out.print("========================================\n", .{});
if (events.len == 0) {
try cli.printFg(out, color, cli.CLR_MUTED, " No earnings data found.\n\n", .{});
return;
}
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("{s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}\n", .{
"Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %",
});
try out.print("{s:->12} {s:->4} {s:->12} {s:->12} {s:->12} {s:->10}\n", .{
"", "", "", "", "", "",
});
try cli.reset(out, color);
for (events) |e| {
var row_buf: [128]u8 = undefined;
const row = fmt.fmtEarningsRow(&row_buf, e);
const rgb = if (row.is_future)
cli.CLR_MUTED
else if (row.is_positive)
cli.CLR_POSITIVE
else
cli.CLR_NEGATIVE;
try cli.printFg(out, color, rgb, "{s}", .{row.text});
try out.print("\n", .{});
}
try out.print("\n{d} earnings event(s)\n\n", .{events.len});
}
// ── Tests ────────────────────────────────────────────────────
test "parseArgs: accepts a single symbol" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"NVDA"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("NVDA", 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{ "NVDA", "extra" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "display shows earnings with beat" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const events = [_]zfin.EarningsEvent{
.{ .symbol = "AAPL", .date = .{ .days = 19000 }, .quarter = 4, .estimate = 1.50, .actual = 1.65, .surprise = 0.15, .surprise_percent = 10.0, .report_time = .amc },
};
try display(&events, "AAPL", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Q4") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$1.50") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$1.65") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "1 earnings event(s)") != null);
}
test "display shows empty message" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const events = [_]zfin.EarningsEvent{};
try display(&events, "XYZ", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "No earnings data found") != null);
}
test "display no ANSI without color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const events = [_]zfin.EarningsEvent{
.{ .symbol = "MSFT", .date = .{ .days = 19000 }, .report_time = .bmo },
};
try display(&events, "MSFT", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}