//! Tiingo provider -- official REST API for end-of-day prices and corporate actions. //! //! Free tier: 50 requests/hour and 1,000 requests/day. We enforce the //! hourly cap with a 50/hour token bucket (`RateLimiter.perHour`, //! which starts full so a nightly refresh burst runs unthrottled); //! the daily ceiling is far from binding for zfin's bursty usage. //! Covers stocks, ETFs, and mutual funds with same-day NAV updates //! (mutual fund NAVs available after midnight ET). //! //! API docs: https://www.tiingo.com/documentation/end-of-day //! //! ## Role in the data pipeline //! //! Tiingo is the **primary candle provider**. Yahoo is the fallback //! when Tiingo can't serve a symbol. Tiingo's `/daily//prices` //! response also carries per-row `divCash` and `splitFactor`, which //! we extract during candle parsing as a free side benefit — the //! candle, dividend, and split data all come from a single HTTP call. //! //! For dividends and splits the **primary source is Polygon**, not //! Tiingo. Polygon's dedicated corporate-actions endpoints carry //! forward-looking declared events (e.g. ARCC's next ex-dividend //! date several months out) that Tiingo's price-series response //! cannot provide — Tiingo only reports events that have already //! affected a price bar. Polygon also carries richer metadata per //! dividend (`pay_date`, `record_date`, `type`, `currency`). //! //! Tiingo's dividend/split contribution is **supplementary**. The //! `populateAllFromTiingo` orchestration in `service.zig` writes //! Tiingo's view through `cache.Store.writeWithSource(..., "tiingo")`, //! which dispatches to the sorted-union merge primitive. Polygon's //! existing entries in `dividends.srf` / `splits.srf` are preserved //! on key collision; Tiingo entries that name new ex_dates / split //! dates are merged in and logged at `info(cache)` level. //! //! The canonical case where Tiingo's supplementary view rescues the //! cache is SPYM's 2017-10-16 4:1 split — present in Tiingo's //! historical bars but absent from Polygon's splits endpoint. Without //! the merge, SPYM's 10Y price-only return would be off by ~14pp. //! //! ## Tiingo dividend records carry less metadata //! //! Tiingo only emits `divCash` (the cash amount) per dividend event. //! When Tiingo merges a previously-unseen ex_date into the cache, //! `pay_date`, `record_date`, `type`, and `currency` will be `null` //! / `.unknown`. The total-return calculation only needs `ex_date` //! and `amount`, both of which Tiingo provides; `divs.zig` //! gracefully handles missing display fields. const std = @import("std"); const http = @import("../net/http.zig"); const RateLimiter = @import("../net/RateLimiter.zig"); const Date = @import("../Date.zig"); const Candle = @import("../models/candle.zig").Candle; const Dividend = @import("../models/dividend.zig").Dividend; const Split = @import("../models/split.zig").Split; const json_utils = @import("json_utils.zig"); const optFloat = json_utils.optFloat; const jsonStr = json_utils.jsonStr; const base_url = "https://api.tiingo.com/tiingo/daily"; /// Combined fetch result: candles, dividends, and splits parsed from /// a single `/daily//prices` response. Caller owns all three /// slices and must free them (and `Dividend.deinit` each entry for /// the currency string). pub const CandleAndCorporateActions = struct { candles: []Candle, dividends: []Dividend, splits: []Split, }; pub const Tiingo = struct { client: http.Client, allocator: std.mem.Allocator, api_key: []const u8, /// Free-tier hourly cap (50/hour). Starts full so a nightly /// refresh burst (one candle file per held symbol) isn't paced; /// sustained usage beyond 50/hour blocks in `acquire`. rate_limiter: RateLimiter, pub fn init(io: std.Io, allocator: std.mem.Allocator, api_key: []const u8) Tiingo { return .{ .client = http.Client.init(io, allocator), .allocator = allocator, .api_key = api_key, .rate_limiter = RateLimiter.perHour(io, 50), }; } pub fn deinit(self: *Tiingo) void { self.client.deinit(); } /// Fetch candles, dividends, and splits in one HTTP call. This is /// the primary provider entry point — the three convenience /// methods below all call this and free the slices they don't /// need. pub fn fetchCandlesAndCorporateActions( self: *Tiingo, allocator: std.mem.Allocator, symbol: []const u8, from: Date, to: Date, ) !CandleAndCorporateActions { var from_buf: [10]u8 = undefined; var to_buf: [10]u8 = undefined; // Date's `{f}` output is exactly 10 bytes (YYYY-MM-DD), so // these cannot fail in practice; `try` instead of // `catch unreachable` to keep the error path honest. const from_str = try std.fmt.bufPrint(&from_buf, "{f}", .{from}); const to_str = try std.fmt.bufPrint(&to_buf, "{f}", .{to}); const symbol_url = try std.fmt.allocPrint(allocator, base_url ++ "/{s}/prices", .{symbol}); defer allocator.free(symbol_url); const url = try http.buildUrl(allocator, symbol_url, &.{ .{ "startDate", from_str }, .{ "endDate", to_str }, .{ "token", self.api_key }, }); defer allocator.free(url); // Honor Tiingo's 50/hour free-tier cap. Blocks only once a // single run has spent its 50-token burst within the hour. self.rate_limiter.acquire(); var response = try self.client.get(url); defer response.deinit(); return parseAll(allocator, response.body); } /// Fetch daily candles for a symbol between two dates (inclusive). /// Convenience wrapper around `fetchCandlesAndCorporateActions` /// for callers that only want the candle slice. pub fn fetchCandles( self: *Tiingo, allocator: std.mem.Allocator, symbol: []const u8, from: Date, to: Date, ) ![]Candle { const triple = try self.fetchCandlesAndCorporateActions(allocator, symbol, from, to); Dividend.freeSlice(allocator, triple.dividends); allocator.free(triple.splits); return triple.candles; } /// Fetch dividends for a symbol between two dates (inclusive). /// Convenience wrapper around `fetchCandlesAndCorporateActions`. pub fn fetchDividends( self: *Tiingo, allocator: std.mem.Allocator, symbol: []const u8, from: Date, to: Date, ) ![]Dividend { const triple = try self.fetchCandlesAndCorporateActions(allocator, symbol, from, to); allocator.free(triple.candles); allocator.free(triple.splits); return triple.dividends; } /// Fetch splits for a symbol between two dates (inclusive). /// Convenience wrapper around `fetchCandlesAndCorporateActions`. pub fn fetchSplits( self: *Tiingo, allocator: std.mem.Allocator, symbol: []const u8, from: Date, to: Date, ) ![]Split { const triple = try self.fetchCandlesAndCorporateActions(allocator, symbol, from, to); allocator.free(triple.candles); Dividend.freeSlice(allocator, triple.dividends); return triple.splits; } }; /// Walk Tiingo's JSON array of price rows once, emitting candles, /// dividends (where `divCash != 0`), and splits (where `splitFactor != 1`). fn parseAll(allocator: std.mem.Allocator, body: []const u8) !CandleAndCorporateActions { const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return error.ParseError; defer parsed.deinit(); const items = switch (parsed.value) { .array => |a| a.items, // Tiingo returns an object with "detail" on errors (bad symbol, auth, etc.) else => return error.RequestFailed, }; var candles: std.ArrayList(Candle) = .empty; errdefer candles.deinit(allocator); var dividends: std.ArrayList(Dividend) = .empty; errdefer { for (dividends.items) |d| d.deinit(allocator); dividends.deinit(allocator); } var splits: std.ArrayList(Split) = .empty; errdefer splits.deinit(allocator); for (items) |item| { const obj = switch (item) { .object => |o| o, else => continue, }; const date = parseDate(obj.get("date")) orelse continue; const close = optFloat(obj.get("close")) orelse continue; try candles.append(allocator, .{ .date = date, .open = optFloat(obj.get("open")) orelse close, .high = optFloat(obj.get("high")) orelse close, .low = optFloat(obj.get("low")) orelse close, .close = close, .adj_close = optFloat(obj.get("adjClose")) orelse close, .volume = blk: { const v = optFloat(obj.get("volume")) orelse break :blk 0; break :blk @intFromFloat(@max(0, v)); }, }); // Dividend event on this row (if any) const div_cash = optFloat(obj.get("divCash")) orelse 0; if (div_cash != 0) { try dividends.append(allocator, .{ .ex_date = date, .amount = div_cash, // Tiingo doesn't carry pay_date / record_date / // type. Display-only fields stay null / .unknown; // total-return math only needs ex_date and amount. }); } // Split event on this row (if any). Tiingo represents a 4:1 // split as splitFactor = 4.0 and a 1:10 reverse split as // splitFactor = 0.1. Both shapes are stored as // numerator=splitFactor, denominator=1.0; `Split.ratio()` // returns splitFactor in either case. const split_factor = optFloat(obj.get("splitFactor")) orelse 1.0; if (split_factor != 1.0 and split_factor != 0) { try splits.append(allocator, .{ .date = date, .numerator = split_factor, .denominator = 1.0, }); } } return .{ .candles = try candles.toOwnedSlice(allocator), .dividends = try dividends.toOwnedSlice(allocator), .splits = try splits.toOwnedSlice(allocator), }; } /// Parse a Tiingo date string (e.g. "2026-03-16T00:00:00.000Z") into a Date. fn parseDate(val: ?std.json.Value) ?Date { const s = jsonStr(val) orelse return null; if (s.len < 10) return null; return Date.parse(s[0..10]) catch null; } // -- Tests -- test "parseAll basic candles, no events" { const body = \\[ \\ { \\ "date": "2026-03-13T00:00:00.000Z", \\ "close": 42.41, "high": 42.41, "low": 42.41, "open": 42.41, \\ "volume": 0, "adjClose": 42.41, "adjHigh": 42.41, \\ "adjLow": 42.41, "adjOpen": 42.41, "adjVolume": 0, \\ "divCash": 0.0, "splitFactor": 1.0 \\ }, \\ { \\ "date": "2026-03-16T00:00:00.000Z", \\ "close": 42.74, "high": 42.74, "low": 42.74, "open": 42.74, \\ "volume": 0, "adjClose": 42.74, "adjHigh": 42.74, \\ "adjLow": 42.74, "adjOpen": 42.74, "adjVolume": 0, \\ "divCash": 0.0, "splitFactor": 1.0 \\ } \\] ; const allocator = std.testing.allocator; const triple = try parseAll(allocator, body); defer allocator.free(triple.candles); defer Dividend.freeSlice(allocator, triple.dividends); defer allocator.free(triple.splits); try std.testing.expectEqual(@as(usize, 2), triple.candles.len); try std.testing.expectEqual(@as(usize, 0), triple.dividends.len); try std.testing.expectEqual(@as(usize, 0), triple.splits.len); try std.testing.expectEqual(@as(i16, 2026), triple.candles[0].date.year()); try std.testing.expectApproxEqAbs(@as(f64, 42.41), triple.candles[0].close, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 42.74), triple.candles[1].close, 0.01); } test "parseAll extracts a dividend from a divCash row" { // NKE 2024-03-01 dividend of $0.37 (real Tiingo response shape) const body = \\[ \\ { \\ "date": "2024-03-01T00:00:00.000Z", \\ "close": 101.88, "high": 103.94, "low": 101.83, "open": 103.87, \\ "volume": 7349270, "adjClose": 97.5550917628, \\ "divCash": 0.37, "splitFactor": 1.0 \\ } \\] ; const allocator = std.testing.allocator; const triple = try parseAll(allocator, body); defer allocator.free(triple.candles); defer Dividend.freeSlice(allocator, triple.dividends); defer allocator.free(triple.splits); try std.testing.expectEqual(@as(usize, 1), triple.candles.len); try std.testing.expectEqual(@as(usize, 1), triple.dividends.len); try std.testing.expectEqual(@as(usize, 0), triple.splits.len); const div = triple.dividends[0]; try std.testing.expect(div.ex_date.eql(Date.fromYmd(2024, 3, 1))); try std.testing.expectApproxEqAbs(@as(f64, 0.37), div.amount, 0.001); // Metadata fields are absent for Tiingo-sourced dividends try std.testing.expect(div.pay_date == null); try std.testing.expect(div.record_date == null); try std.testing.expectEqual(@import("../models/dividend.zig").DividendType.unknown, div.type); } test "parseAll extracts forward 4:1 split (SPYM 2017 fixture)" { // SPYM's actual 2017-10-16 split — verbatim Tiingo response shape. // Polygon and FMP both miss this split; Tiingo has it via // splitFactor: 4.0. const body = \\[ \\ { \\ "date": "2017-10-13T00:00:00.000Z", \\ "close": 119.7493, "open": 119.7667, "high": 120.26, "low": 119.7396, \\ "volume": 7638, "adjClose": 26.0674934371, \\ "divCash": 0.0, "splitFactor": 1.0 \\ }, \\ { \\ "date": "2017-10-16T00:00:00.000Z", \\ "close": 29.9556, "open": 30.01, "high": 30.01, "low": 29.9399, \\ "volume": 8804, "adjClose": 26.0810001294, \\ "divCash": 0.0, "splitFactor": 4.0 \\ }, \\ { \\ "date": "2017-10-17T00:00:00.000Z", \\ "close": 29.92, "open": 29.95, "high": 30.05, "low": 29.92, \\ "volume": 21456, "adjClose": 26.0524079435, \\ "divCash": 0.0, "splitFactor": 1.0 \\ } \\] ; const allocator = std.testing.allocator; const triple = try parseAll(allocator, body); defer allocator.free(triple.candles); defer Dividend.freeSlice(allocator, triple.dividends); defer allocator.free(triple.splits); try std.testing.expectEqual(@as(usize, 3), triple.candles.len); try std.testing.expectEqual(@as(usize, 0), triple.dividends.len); try std.testing.expectEqual(@as(usize, 1), triple.splits.len); const split = triple.splits[0]; try std.testing.expect(split.date.eql(Date.fromYmd(2017, 10, 16))); try std.testing.expectApproxEqAbs(@as(f64, 4.0), split.numerator, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 1.0), split.denominator, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 4.0), split.ratio(), 0.001); } test "parseAll extracts reverse 1:10 split (splitFactor < 1)" { // Reverse split: 1:10 means splitFactor = 0.1 const body = \\[ \\ { \\ "date": "2024-06-10T00:00:00.000Z", \\ "close": 50.0, "open": 5.0, "high": 50.0, "low": 5.0, \\ "volume": 1000, "adjClose": 50.0, \\ "divCash": 0.0, "splitFactor": 0.1 \\ } \\] ; const allocator = std.testing.allocator; const triple = try parseAll(allocator, body); defer allocator.free(triple.candles); defer Dividend.freeSlice(allocator, triple.dividends); defer allocator.free(triple.splits); try std.testing.expectEqual(@as(usize, 1), triple.splits.len); const split = triple.splits[0]; try std.testing.expectApproxEqAbs(@as(f64, 0.1), split.ratio(), 0.001); } test "parseAll: combined dividend + split in same response" { const body = \\[ \\ {"date": "2024-01-15T00:00:00.000Z", "close": 100.0, "open": 100.0, "high": 100.0, "low": 100.0, \\ "volume": 0, "adjClose": 100.0, "divCash": 0.5, "splitFactor": 1.0}, \\ {"date": "2024-06-10T00:00:00.000Z", "close": 25.0, "open": 100.0, "high": 100.0, "low": 25.0, \\ "volume": 0, "adjClose": 25.0, "divCash": 0.0, "splitFactor": 4.0}, \\ {"date": "2024-12-15T00:00:00.000Z", "close": 30.0, "open": 30.0, "high": 30.0, "low": 30.0, \\ "volume": 0, "adjClose": 30.0, "divCash": 0.15, "splitFactor": 1.0} \\] ; const allocator = std.testing.allocator; const triple = try parseAll(allocator, body); defer allocator.free(triple.candles); defer Dividend.freeSlice(allocator, triple.dividends); defer allocator.free(triple.splits); try std.testing.expectEqual(@as(usize, 3), triple.candles.len); try std.testing.expectEqual(@as(usize, 2), triple.dividends.len); try std.testing.expectEqual(@as(usize, 1), triple.splits.len); try std.testing.expectApproxEqAbs(@as(f64, 0.5), triple.dividends[0].amount, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.15), triple.dividends[1].amount, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 4.0), triple.splits[0].ratio(), 0.001); } test "parseAll: large dividend (VPMAX-style cap-gains distribution)" { // VPMAX's 2025-12-17 distribution of $30.43 — chunky year-end // cap-gains payout that inflates 1Y total return because Tiingo // (and Polygon) lump it under regular dividends. const body = \\[ \\ {"date": "2025-12-17T00:00:00.000Z", "close": 214.0, "open": 244.43, "high": 244.43, "low": 213.5, \\ "volume": 0, "adjClose": 214.0, "divCash": 30.429903, "splitFactor": 1.0} \\] ; const allocator = std.testing.allocator; const triple = try parseAll(allocator, body); defer allocator.free(triple.candles); defer Dividend.freeSlice(allocator, triple.dividends); defer allocator.free(triple.splits); try std.testing.expectEqual(@as(usize, 1), triple.dividends.len); try std.testing.expectApproxEqAbs(@as(f64, 30.429903), triple.dividends[0].amount, 0.000001); } test "parseAll error response" { const body = \\{"detail": "Not found."} ; const allocator = std.testing.allocator; const result = parseAll(allocator, body); try std.testing.expectError(error.RequestFailed, result); } test "parseAll empty array" { const body = "[]"; const allocator = std.testing.allocator; const triple = try parseAll(allocator, body); defer allocator.free(triple.candles); defer Dividend.freeSlice(allocator, triple.dividends); defer allocator.free(triple.splits); try std.testing.expectEqual(@as(usize, 0), triple.candles.len); try std.testing.expectEqual(@as(usize, 0), triple.dividends.len); try std.testing.expectEqual(@as(usize, 0), triple.splits.len); }