zfin/src/providers/finnhub.zig

245 lines
7.6 KiB
Zig

//! 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);
}