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