245 lines
7.6 KiB
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);
|
|
}
|