zfin/src/commands/earnings.zig

144 lines
5.7 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.getEarnings(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n");
return;
},
else => {
try cli.stderrPrint("Error fetching earnings data.\n");
return;
},
};
defer allocator.free(result.data);
if (result.source == .cached) try cli.stderrPrint("(using cached earnings data)\n");
try display(result.data, symbol, color, out);
}
pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
try cli.setBold(out, color);
try out.print("\nEarnings History for {s}\n", .{symbol});
try cli.reset(out, color);
try out.print("========================================\n", .{});
if (events.len == 0) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" No earnings data found.\n\n", .{});
try cli.reset(out, color);
return;
}
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("{s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10} {s:>5}\n", .{
"Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %", "When",
});
try out.print("{s:->12} {s:->4} {s:->12} {s:->12} {s:->12} {s:->10} {s:->5}\n", .{
"", "", "", "", "", "", "",
});
try cli.reset(out, color);
for (events) |e| {
var db: [10]u8 = undefined;
const is_future = e.isFuture();
const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true;
if (is_future) {
try cli.setFg(out, color, cli.CLR_MUTED);
} else if (surprise_positive) {
try cli.setFg(out, color, cli.CLR_POSITIVE);
} else {
try cli.setFg(out, color, cli.CLR_NEGATIVE);
}
try out.print("{s:>12}", .{e.date.format(&db)});
if (e.quarter) |q| try out.print(" Q{d}", .{q}) else try out.print(" {s:>4}", .{"--"});
if (e.estimate) |est| try out.print(" {s:>12}", .{fmtEps(est)}) else try out.print(" {s:>12}", .{"--"});
if (e.actual) |act| try out.print(" {s:>12}", .{fmtEps(act)}) else try out.print(" {s:>12}", .{"--"});
if (e.surpriseAmount()) |s| {
var surp_buf: [12]u8 = undefined;
const surp_str = if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?";
try out.print(" {s:>12}", .{surp_str});
} else {
try out.print(" {s:>12}", .{"--"});
}
if (e.surprisePct()) |sp| {
var pct_buf: [12]u8 = undefined;
const pct_str = if (sp >= 0) std.fmt.bufPrint(&pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&pct_buf, "{d:.1}%", .{sp}) catch "?";
try out.print(" {s:>10}", .{pct_str});
} else {
try out.print(" {s:>10}", .{"--"});
}
try out.print(" {s:>5}", .{@tagName(e.report_time)});
try cli.reset(out, color);
try out.print("\n", .{});
}
try out.print("\n{d} earnings event(s)\n\n", .{events.len});
}
pub fn fmtEps(val: f64) [12]u8 {
var buf: [12]u8 = .{' '} ** 12;
_ = std.fmt.bufPrint(&buf, "${d:.2}", .{val}) catch {};
return buf;
}
// ── Tests ────────────────────────────────────────────────────
test "fmtEps formats positive value" {
const result = fmtEps(1.25);
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
try std.testing.expectEqualStrings("$1.25", trimmed);
}
test "fmtEps formats negative value" {
const result = fmtEps(-0.50);
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
try std.testing.expect(std.mem.indexOf(u8, trimmed, "0.5") != null);
}
test "fmtEps formats zero" {
const result = fmtEps(0.0);
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
try std.testing.expectEqualStrings("$0.00", trimmed);
}
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);
}