410 lines
13 KiB
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);
|
|
}
|