zfin/src/providers/polygon.zig

410 lines
13 KiB
Zig

//! Polygon.io API provider -- primary source for dividend/split reference data.
//! API docs: https://polygon.io/docs
//!
//! Free tier: 5 requests/min, unlimited daily.
//! Dividends and splits are available for all history.
const std = @import("std");
const http = @import("../net/http.zig");
const RateLimiter = @import("../net/RateLimiter.zig");
const Date = @import("../Date.zig");
const Dividend = @import("../models/dividend.zig").Dividend;
const DividendType = @import("../models/dividend.zig").DividendType;
const Split = @import("../models/split.zig").Split;
const json_utils = @import("json_utils.zig");
const parseJsonFloat = json_utils.parseJsonFloat;
const jsonStr = json_utils.jsonStr;
const base_url = "https://api.polygon.io";
pub const Polygon = struct {
api_key: []const u8,
client: http.Client,
rate_limiter: RateLimiter,
allocator: std.mem.Allocator,
pub fn init(io: std.Io, allocator: std.mem.Allocator, api_key: []const u8) Polygon {
return .{
.api_key = api_key,
.client = http.Client.init(io, allocator),
.rate_limiter = RateLimiter.perMinute(io, 5),
.allocator = allocator,
};
}
pub fn deinit(self: *Polygon) void {
self.client.deinit();
}
/// Fetch dividend history for a ticker. Results sorted oldest-first by ex_date.
/// Polygon endpoint: GET /v3/reference/dividends?ticker=X&ex_dividend_date.gte=YYYY-MM-DD&...
pub fn fetchDividends(
self: *Polygon,
allocator: std.mem.Allocator,
symbol: []const u8,
from: ?Date,
to: ?Date,
) ![]Dividend {
var all_dividends: std.ArrayList(Dividend) = .empty;
errdefer {
for (all_dividends.items) |d| d.deinit(allocator);
all_dividends.deinit(allocator);
}
var next_url: ?[]const u8 = null;
defer if (next_url) |u| allocator.free(u);
// First request
{
self.rate_limiter.acquire();
var params: [5][2][]const u8 = undefined;
var n: usize = 0;
params[n] = .{ "ticker", symbol };
n += 1;
params[n] = .{ "limit", "1000" };
n += 1;
params[n] = .{ "sort", "ex_dividend_date" };
n += 1;
var from_buf: [10]u8 = undefined;
var to_buf: [10]u8 = undefined;
if (from) |f| {
params[n] = .{ "ex_dividend_date.gte", std.fmt.bufPrint(&from_buf, "{f}", .{f}) catch unreachable };
n += 1;
}
if (to) |t| {
params[n] = .{ "ex_dividend_date.lte", std.fmt.bufPrint(&to_buf, "{f}", .{t}) catch unreachable };
n += 1;
}
const url = try http.buildUrl(allocator, base_url ++ "/v3/reference/dividends", params[0..n]);
defer allocator.free(url);
const authed = try appendApiKey(allocator, url, self.api_key);
defer allocator.free(authed);
var response = try self.client.get(authed);
defer response.deinit();
next_url = try parseDividendsPage(allocator, response.body, &all_dividends);
}
// Paginate
while (next_url) |cursor_url| {
self.rate_limiter.acquire();
const authed = try appendApiKey(allocator, cursor_url, self.api_key);
defer allocator.free(authed);
var response = try self.client.get(authed);
defer response.deinit();
// Free the cursor URL we just consumed and clear next_url
// BEFORE attempting to parse. If parseDividendsPage errors,
// the function-scope defer at the top must not see a
// dangling pointer in next_url -- otherwise it double-frees
// the buffer we just released. The new next_url (if any)
// gets assigned below on success.
allocator.free(cursor_url);
next_url = null;
next_url = try parseDividendsPage(allocator, response.body, &all_dividends);
}
return try all_dividends.toOwnedSlice(allocator);
}
/// Fetch split history for a ticker. Results sorted oldest-first.
/// Polygon endpoint: GET /v3/reference/splits?ticker=X&...
pub fn fetchSplits(
self: *Polygon,
allocator: std.mem.Allocator,
symbol: []const u8,
) ![]Split {
self.rate_limiter.acquire();
const url = try http.buildUrl(allocator, base_url ++ "/v3/reference/splits", &.{
.{ "ticker", symbol },
.{ "limit", "1000" },
.{ "sort", "execution_date" },
});
defer allocator.free(url);
const authed = try appendApiKey(allocator, url, self.api_key);
defer allocator.free(authed);
var response = try self.client.get(authed);
defer response.deinit();
return parseSplitsResponse(allocator, response.body);
}
};
// -- JSON parsing --
fn parseDividendsPage(
allocator: std.mem.Allocator,
body: []const u8,
out: *std.ArrayList(Dividend),
) !?[]const u8 {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
return error.ParseError;
defer parsed.deinit();
const root = parsed.value.object;
// Check status
if (root.get("status")) |s| {
if (s == .string and std.mem.eql(u8, s.string, "ERROR"))
return error.RequestFailed;
}
const results = root.get("results") orelse return null;
const items = switch (results) {
.array => |a| a.items,
else => return null,
};
for (items) |item| {
const obj = switch (item) {
.object => |o| o,
else => continue,
};
const ex_date = blk: {
const v = obj.get("ex_dividend_date") orelse continue;
const s = switch (v) {
.string => |str| str,
else => continue,
};
break :blk Date.parse(s) catch continue;
};
const amount = parseJsonFloat(obj.get("cash_amount"));
if (amount <= 0) continue;
try out.append(allocator, .{
.ex_date = ex_date,
.amount = amount,
.pay_date = parseDateField(obj, "pay_date"),
.record_date = parseDateField(obj, "record_date"),
.type = parseDividendType(obj),
.currency = if (jsonStr(obj.get("currency"))) |s|
(allocator.dupe(u8, s) catch null)
else
null,
});
}
// Check for next_url (pagination cursor)
if (root.get("next_url")) |nu| {
const url_str = switch (nu) {
.string => |s| s,
else => return null,
};
const duped = try allocator.dupe(u8, url_str);
return duped;
}
return null;
}
fn parseSplitsResponse(allocator: std.mem.Allocator, body: []const u8) ![]Split {
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("status")) |s| {
if (s == .string and std.mem.eql(u8, s.string, "ERROR"))
return error.RequestFailed;
}
const results = root.get("results") orelse {
return try allocator.alloc(Split, 0);
};
const items = switch (results) {
.array => |a| a.items,
else => {
return try allocator.alloc(Split, 0);
},
};
var splits: std.ArrayList(Split) = .empty;
errdefer splits.deinit(allocator);
for (items) |item| {
const obj = switch (item) {
.object => |o| o,
else => continue,
};
const date = blk: {
const v = obj.get("execution_date") orelse continue;
const s = switch (v) {
.string => |str| str,
else => continue,
};
break :blk Date.parse(s) catch continue;
};
try splits.append(allocator, .{
.date = date,
.numerator = parseJsonFloat(obj.get("split_to")),
.denominator = parseJsonFloat(obj.get("split_from")),
});
}
return try splits.toOwnedSlice(allocator);
}
// -- Helpers --
fn appendApiKey(allocator: std.mem.Allocator, url: []const u8, api_key: []const u8) ![]const u8 {
const sep: u8 = if (std.mem.indexOfScalar(u8, url, '?') != null) '&' else '?';
return std.fmt.allocPrint(allocator, "{s}{c}apiKey={s}", .{ url, sep, api_key });
}
fn parseDateField(obj: std.json.ObjectMap, key: []const u8) ?Date {
const v = obj.get(key) orelse return null;
const s = switch (v) {
.string => |str| str,
else => return null,
};
return Date.parse(s) catch null;
}
fn parseDividendType(obj: std.json.ObjectMap) DividendType {
const v = obj.get("dividend_type") orelse return .unknown;
const s = switch (v) {
.string => |str| str,
else => return .unknown,
};
if (std.mem.eql(u8, s, "CD")) return .regular;
if (std.mem.eql(u8, s, "SC")) return .special;
if (std.mem.eql(u8, s, "LT") or std.mem.eql(u8, s, "ST")) return .regular;
return .unknown;
}
// -- Tests --
test "parseDividendsPage basic" {
const body =
\\{
\\ "status": "OK",
\\ "results": [
\\ {
\\ "ex_dividend_date": "2024-08-12",
\\ "cash_amount": 0.25,
\\ "pay_date": "2024-08-15",
\\ "record_date": "2024-08-13",
\\ "frequency": 4,
\\ "dividend_type": "CD",
\\ "currency": "USD"
\\ },
\\ {
\\ "ex_dividend_date": "2024-05-10",
\\ "cash_amount": 0.25,
\\ "dividend_type": "SC"
\\ }
\\ ]
\\}
;
const allocator = std.testing.allocator;
var out = std.ArrayList(Dividend).empty;
defer {
for (out.items) |d| d.deinit(allocator);
out.deinit(allocator);
}
const next_url = try parseDividendsPage(allocator, body, &out);
try std.testing.expect(next_url == null);
try std.testing.expectEqual(@as(usize, 2), out.items.len);
try std.testing.expect(out.items[0].ex_date.eql(Date.fromYmd(2024, 8, 12)));
try std.testing.expectApproxEqAbs(@as(f64, 0.25), out.items[0].amount, 0.001);
try std.testing.expect(out.items[0].pay_date != null);
try std.testing.expectEqual(DividendType.regular, out.items[0].type);
try std.testing.expectEqual(DividendType.special, out.items[1].type);
}
test "parseDividendsPage with pagination" {
const body =
\\{
\\ "status": "OK",
\\ "results": [
\\ {"ex_dividend_date": "2024-01-10", "cash_amount": 0.50}
\\ ],
\\ "next_url": "https://api.polygon.io/v3/reference/dividends?cursor=abc123"
\\}
;
const allocator = std.testing.allocator;
var out = std.ArrayList(Dividend).empty;
defer {
for (out.items) |d| d.deinit(allocator);
out.deinit(allocator);
}
const next_url = try parseDividendsPage(allocator, body, &out);
try std.testing.expect(next_url != null);
defer allocator.free(next_url.?);
try std.testing.expectEqualStrings("https://api.polygon.io/v3/reference/dividends?cursor=abc123", next_url.?);
try std.testing.expectEqual(@as(usize, 1), out.items.len);
}
test "parseDividendsPage error status" {
const body =
\\{"status": "ERROR", "error": "Bad request"}
;
const allocator = std.testing.allocator;
var out = std.ArrayList(Dividend).empty;
defer {
for (out.items) |d| d.deinit(allocator);
out.deinit(allocator);
}
const result = parseDividendsPage(allocator, body, &out);
try std.testing.expectError(error.RequestFailed, result);
}
test "parseSplitsResponse basic" {
const body =
\\{
\\ "status": "OK",
\\ "results": [
\\ {"execution_date": "2020-08-31", "split_to": 4, "split_from": 1},
\\ {"execution_date": "2014-06-09", "split_to": 7, "split_from": 1}
\\ ]
\\}
;
const allocator = std.testing.allocator;
const splits = try parseSplitsResponse(allocator, body);
defer allocator.free(splits);
try std.testing.expectEqual(@as(usize, 2), splits.len);
try std.testing.expect(splits[0].date.eql(Date.fromYmd(2020, 8, 31)));
try std.testing.expectApproxEqAbs(@as(f64, 4.0), splits[0].numerator, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), splits[0].denominator, 0.001);
}
test "parseSplitsResponse empty results" {
const body =
\\{"status": "OK", "results": []}
;
const allocator = std.testing.allocator;
const splits = try parseSplitsResponse(allocator, body);
defer allocator.free(splits);
try std.testing.expectEqual(@as(usize, 0), splits.len);
}