zfin/src/providers/tiingo.zig

198 lines
6.8 KiB
Zig

//! Tiingo provider -- official REST API for end-of-day prices.
//!
//! Free tier: 1,000 requests/day, no per-minute restriction.
//! 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
const std = @import("std");
const http = @import("../net/http.zig");
const Date = @import("../Date.zig");
const Candle = @import("../models/candle.zig").Candle;
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";
pub const Tiingo = struct {
client: http.Client,
allocator: std.mem.Allocator,
api_key: []const u8,
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,
};
}
pub fn deinit(self: *Tiingo) void {
self.client.deinit();
}
/// Fetch daily candles for a symbol between two dates (inclusive).
/// Returns candles sorted oldest-first.
pub fn fetchCandles(
self: *Tiingo,
allocator: std.mem.Allocator,
symbol: []const u8,
from: Date,
to: Date,
) ![]Candle {
var from_buf: [10]u8 = undefined;
var to_buf: [10]u8 = undefined;
const from_str = std.fmt.bufPrint(&from_buf, "{f}", .{from}) catch unreachable;
const to_str = std.fmt.bufPrint(&to_buf, "{f}", .{to}) catch unreachable;
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);
var response = try self.client.get(url);
defer response.deinit();
return parseCandles(allocator, response.body);
}
};
/// Parse Tiingo's JSON array of price objects into Candles.
/// Tiingo returns oldest-first, which matches our convention.
fn parseCandles(allocator: std.mem.Allocator, body: []const u8) ![]Candle {
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);
}
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));
},
});
}
return candles.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 "parseCandles basic" {
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 candles = try parseCandles(allocator, body);
defer allocator.free(candles);
try std.testing.expectEqual(@as(usize, 2), candles.len);
// Oldest first
try std.testing.expectEqual(@as(i16, 2026), candles[0].date.year());
try std.testing.expectEqual(@as(u8, 3), candles[0].date.month());
try std.testing.expectEqual(@as(u8, 13), candles[0].date.day());
try std.testing.expectApproxEqAbs(@as(f64, 42.41), candles[0].close, 0.01);
try std.testing.expectEqual(@as(u8, 16), candles[1].date.day());
try std.testing.expectApproxEqAbs(@as(f64, 42.74), candles[1].close, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 42.74), candles[1].adj_close, 0.01);
}
test "parseCandles stock with volume" {
const body =
\\[
\\ {
\\ "date": "2026-03-16T00:00:00.000Z",
\\ "close": 183.22, "high": 185.10, "low": 180.50, "open": 181.00,
\\ "volume": 217307380, "adjClose": 183.22, "adjHigh": 185.10,
\\ "adjLow": 180.50, "adjOpen": 181.00, "adjVolume": 217307380,
\\ "divCash": 0.0, "splitFactor": 1.0
\\ }
\\]
;
const allocator = std.testing.allocator;
const candles = try parseCandles(allocator, body);
defer allocator.free(candles);
try std.testing.expectEqual(@as(usize, 1), candles.len);
try std.testing.expectApproxEqAbs(@as(f64, 181.00), candles[0].open, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 185.10), candles[0].high, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 180.50), candles[0].low, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 183.22), candles[0].close, 0.01);
try std.testing.expectEqual(@as(u64, 217307380), candles[0].volume);
}
test "parseCandles error response" {
const body =
\\{"detail": "Not found."}
;
const allocator = std.testing.allocator;
const result = parseCandles(allocator, body);
try std.testing.expectError(error.RequestFailed, result);
}
test "parseCandles empty array" {
const body = "[]";
const allocator = std.testing.allocator;
const candles = try parseCandles(allocator, body);
defer allocator.free(candles);
try std.testing.expectEqual(@as(usize, 0), candles.len);
}