198 lines
6.8 KiB
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);
|
|
}
|