//! Finnhub API provider -- primary source for earnings data. //! API docs: https://finnhub.io/docs/api //! //! Free tier: 60 requests/min, all US market data. //! //! Earnings endpoint: GET /api/v1/calendar/earnings?symbol=X&from=YYYY-MM-DD&to=YYYY-MM-DD //! Returns historical and upcoming earnings with EPS, revenue, estimates. const std = @import("std"); const http = @import("../net/http.zig"); const RateLimiter = @import("../net/RateLimiter.zig"); const Date = @import("../models/date.zig").Date; const EarningsEvent = @import("../models/earnings.zig").EarningsEvent; const ReportTime = @import("../models/earnings.zig").ReportTime; const json_utils = @import("json_utils.zig"); const optFloat = json_utils.optFloat; const jsonStr = json_utils.jsonStr; const base_url = "https://api.finnhub.io/api/v1"; pub const Finnhub = struct { api_key: []const u8, client: http.Client, rate_limiter: RateLimiter, allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator, api_key: []const u8) Finnhub { return .{ .api_key = api_key, .client = http.Client.init(allocator), .rate_limiter = RateLimiter.perMinute(60), .allocator = allocator, }; } pub fn deinit(self: *Finnhub) void { self.client.deinit(); } /// Fetch earnings calendar for a symbol. /// Returns earnings events sorted newest-first (upcoming first, then historical). pub fn fetchEarnings( self: *Finnhub, allocator: std.mem.Allocator, symbol: []const u8, from: ?Date, to: ?Date, ) ![]EarningsEvent { self.rate_limiter.acquire(); var params: [4][2][]const u8 = undefined; var n: usize = 0; params[n] = .{ "symbol", symbol }; n += 1; params[n] = .{ "token", self.api_key }; n += 1; var from_buf: [10]u8 = undefined; var to_buf: [10]u8 = undefined; if (from) |f| { params[n] = .{ "from", f.format(&from_buf) }; n += 1; } if (to) |t| { params[n] = .{ "to", t.format(&to_buf) }; n += 1; } const url = try http.buildUrl(allocator, base_url ++ "/calendar/earnings", params[0..n]); defer allocator.free(url); var response = try self.client.get(url); defer response.deinit(); return parseEarningsResponse(allocator, response.body, symbol); } }; // -- JSON parsing -- fn parseEarningsResponse( allocator: std.mem.Allocator, body: []const u8, symbol: []const u8, ) ![]EarningsEvent { const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return error.ParseError; defer parsed.deinit(); const root = parsed.value.object; if (root.get("error")) |_| return error.RequestFailed; const cal = root.get("earningsCalendar") orelse { return try allocator.alloc(EarningsEvent, 0); }; const items = switch (cal) { .array => |a| a.items, else => { return try allocator.alloc(EarningsEvent, 0); }, }; var events: std.ArrayList(EarningsEvent) = .empty; errdefer events.deinit(allocator); for (items) |item| { const obj = switch (item) { .object => |o| o, else => continue, }; const date_str = jsonStr(obj.get("date")) orelse continue; const date = Date.parse(date_str) catch continue; const actual = optFloat(obj.get("epsActual")); const estimate = optFloat(obj.get("epsEstimate")); const surprise: ?f64 = if (actual != null and estimate != null) actual.? - estimate.? else null; const surprise_pct: ?f64 = if (surprise != null and estimate != null and estimate.? != 0) (surprise.? / @abs(estimate.?)) * 100.0 else null; try events.append(allocator, .{ .symbol = symbol, .date = date, .estimate = estimate, .actual = actual, .surprise = surprise, .surprise_percent = surprise_pct, .quarter = parseQuarter(obj.get("quarter")), .fiscal_year = parseFiscalYear(obj.get("year")), .revenue_actual = optFloat(obj.get("revenueActual")), .revenue_estimate = optFloat(obj.get("revenueEstimate")), .report_time = parseReportTime(obj.get("hour")), }); } return try events.toOwnedSlice(allocator); } // -- Helpers -- fn parseQuarter(val: ?std.json.Value) ?u8 { const v = val orelse return null; const i = switch (v) { .integer => |n| n, .float => |f| @as(i64, @intFromFloat(f)), else => return null, }; return if (i >= 1 and i <= 4) @intCast(i) else null; } fn parseFiscalYear(val: ?std.json.Value) ?i16 { const v = val orelse return null; const i = switch (v) { .integer => |n| n, .float => |f| @as(i64, @intFromFloat(f)), else => return null, }; return if (i > 1900 and i < 2200) @intCast(i) else null; } fn parseReportTime(val: ?std.json.Value) ReportTime { const s = jsonStr(val) orelse return .unknown; if (std.mem.eql(u8, s, "bmo")) return .bmo; if (std.mem.eql(u8, s, "amc")) return .amc; if (std.mem.eql(u8, s, "dmh")) return .dmh; return .unknown; } // -- Tests -- test "parseEarningsResponse basic" { const body = \\{ \\ "earningsCalendar": [ \\ { \\ "date": "2024-10-31", \\ "epsActual": 1.64, \\ "epsEstimate": 1.60, \\ "quarter": 4, \\ "year": 2024, \\ "revenueActual": 94930000000, \\ "revenueEstimate": 94360000000, \\ "hour": "amc" \\ }, \\ { \\ "date": "2025-04-15", \\ "epsEstimate": 1.70, \\ "quarter": 1, \\ "year": 2025, \\ "hour": "bmo" \\ } \\ ] \\} ; const allocator = std.testing.allocator; const events = try parseEarningsResponse(allocator, body, "AAPL"); defer allocator.free(events); try std.testing.expectEqual(@as(usize, 2), events.len); // Past earnings with actual try std.testing.expect(events[0].date.eql(Date.fromYmd(2024, 10, 31))); try std.testing.expectApproxEqAbs(@as(f64, 1.64), events[0].actual.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 1.60), events[0].estimate.?, 0.01); try std.testing.expect(events[0].surprise != null); try std.testing.expectApproxEqAbs(@as(f64, 0.04), events[0].surprise.?, 0.01); try std.testing.expectEqual(@as(?u8, 4), events[0].quarter); try std.testing.expectEqual(@as(?i16, 2024), events[0].fiscal_year); try std.testing.expectEqual(ReportTime.amc, events[0].report_time); // Future earnings without actual try std.testing.expect(events[1].actual == null); try std.testing.expect(events[1].surprise == null); try std.testing.expectEqual(ReportTime.bmo, events[1].report_time); } test "parseEarningsResponse error" { const body = \\{"error": "API limit reached"} ; const allocator = std.testing.allocator; const result = parseEarningsResponse(allocator, body, "AAPL"); try std.testing.expectError(error.RequestFailed, result); } test "parseEarningsResponse empty" { const body = \\{"earningsCalendar": []} ; const allocator = std.testing.allocator; const events = try parseEarningsResponse(allocator, body, "AAPL"); defer allocator.free(events); try std.testing.expectEqual(@as(usize, 0), events.len); }