zfin/src/providers/twelvedata.zig
Emil Lerch d0c13847f5
All checks were successful
Generic zig build / build (push) Successful in 27s
get more conservative with twelvedata
2026-03-11 16:03:23 -07:00

383 lines
13 KiB
Zig

//! Twelve Data API provider -- primary source for historical price data.
//! API docs: https://twelvedata.com/docs
//!
//! Free tier: 800 requests/day, 8 credits/min, all US market data.
//!
//! Note: Twelve Data returns split-adjusted prices but NOT dividend-adjusted.
//! The `adj_close` field is set equal to `close` here. For true total-return
//! calculations, use Polygon dividend data with the manual reinvestment method.
const std = @import("std");
const http = @import("../net/http.zig");
const RateLimiter = @import("../net/RateLimiter.zig");
const Date = @import("../models/date.zig").Date;
const Candle = @import("../models/candle.zig").Candle;
const Quote = @import("../models/quote.zig").Quote;
const json_utils = @import("json_utils.zig");
const parseJsonFloat = json_utils.parseJsonFloat;
const base_url = "https://api.twelvedata.com";
pub const TwelveData = struct {
api_key: []const u8,
client: http.Client,
rate_limiter: RateLimiter,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, api_key: []const u8) TwelveData {
return .{
.api_key = api_key,
.client = http.Client.init(allocator),
// Provider is 8/min, but we seem to be bumping against it, so we
// will be a bit more conservative here. Slow and steady
.rate_limiter = RateLimiter.perMinute(7),
.allocator = allocator,
};
}
pub fn deinit(self: *TwelveData) void {
self.client.deinit();
}
/// Fetch daily candles for a symbol between two dates.
/// Returns candles sorted oldest-first.
pub fn fetchCandles(
self: *TwelveData,
allocator: std.mem.Allocator,
symbol: []const u8,
from: Date,
to: Date,
) ![]Candle {
self.rate_limiter.acquire();
var from_buf: [10]u8 = undefined;
var to_buf: [10]u8 = undefined;
const from_str = from.format(&from_buf);
const to_str = to.format(&to_buf);
// TwelveData's max outputsize is 5000 data points per request.
// For daily candles this covers ~20 years of trading days (~252/year),
// well beyond our typical 10-year lookback. No pagination needed.
const url = try http.buildUrl(allocator, base_url ++ "/time_series", &.{
.{ "symbol", symbol },
.{ "interval", "1day" },
.{ "start_date", from_str },
.{ "end_date", to_str },
.{ "outputsize", "5000" },
.{ "apikey", self.api_key },
});
defer allocator.free(url);
var response = try self.client.get(url);
defer response.deinit();
return parseTimeSeriesResponse(allocator, response.body);
}
pub fn fetchQuote(
self: *TwelveData,
allocator: std.mem.Allocator,
symbol: []const u8,
) !Quote {
self.rate_limiter.acquire();
const url = try http.buildUrl(allocator, base_url ++ "/quote", &.{
.{ "symbol", symbol },
.{ "apikey", self.api_key },
});
defer allocator.free(url);
var response = try self.client.get(url);
defer response.deinit();
return parseQuoteResponse(allocator, response.body, symbol);
}
};
// -- JSON parsing --
fn parseTimeSeriesResponse(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 root = parsed.value;
// Check for API error
if (root.object.get("status")) |status| {
if (status == .string) {
if (std.mem.eql(u8, status.string, "error")) {
// Check error code
if (root.object.get("code")) |code| {
if (code == .integer and code.integer == 429) return error.RateLimited;
if (code == .integer and code.integer == 401) return error.Unauthorized;
}
return error.RequestFailed;
}
}
}
const values_json = root.object.get("values") orelse return error.ParseError;
const values = switch (values_json) {
.array => |a| a.items,
else => return error.ParseError,
};
// Twelve Data returns newest first. We'll parse into a list and reverse.
var candles: std.ArrayList(Candle) = .empty;
errdefer candles.deinit(allocator);
for (values) |val| {
const obj = switch (val) {
.object => |o| o,
else => continue,
};
const date = blk: {
const dt = obj.get("datetime") orelse continue;
const dt_str = switch (dt) {
.string => |s| s,
else => continue,
};
// datetime can be "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS"
const date_part = if (dt_str.len >= 10) dt_str[0..10] else continue;
break :blk Date.parse(date_part) catch continue;
};
try candles.append(allocator, .{
.date = date,
.open = parseJsonFloat(obj.get("open")),
.high = parseJsonFloat(obj.get("high")),
.low = parseJsonFloat(obj.get("low")),
.close = parseJsonFloat(obj.get("close")),
// Twelve Data close is split-adjusted only, not dividend-adjusted
.adj_close = parseJsonFloat(obj.get("close")),
.volume = @intFromFloat(parseJsonFloat(obj.get("volume"))),
});
}
// Reverse to get oldest-first ordering
const slice = try candles.toOwnedSlice(allocator);
std.mem.reverse(Candle, slice);
return slice;
}
fn parseQuoteResponse(allocator: std.mem.Allocator, body: []const u8, symbol: []const u8) !Quote {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
return error.ParseError;
defer parsed.deinit();
const root = parsed.value.object;
// Check for error
if (root.get("status")) |status| {
if (status == .string and std.mem.eql(u8, status.string, "error")) {
if (root.get("code")) |code| {
if (code == .integer and code.integer == 429) return error.RateLimited;
}
return error.RequestFailed;
}
}
const ftw = root.get("fifty_two_week");
return .{
.symbol = symbol,
.name = symbol,
.exchange = "",
.datetime = "",
.close = parseJsonFloat(root.get("close")),
.open = parseJsonFloat(root.get("open")),
.high = parseJsonFloat(root.get("high")),
.low = parseJsonFloat(root.get("low")),
.volume = @intFromFloat(parseJsonFloat(root.get("volume"))),
.previous_close = parseJsonFloat(root.get("previous_close")),
.change = parseJsonFloat(root.get("change")),
.percent_change = parseJsonFloat(root.get("percent_change")),
.average_volume = @intFromFloat(parseJsonFloat(root.get("average_volume"))),
.fifty_two_week_low = if (ftw) |f| switch (f) {
.object => |o| parseJsonFloat(o.get("low")),
else => 0,
} else 0,
.fifty_two_week_high = if (ftw) |f| switch (f) {
.object => |o| parseJsonFloat(o.get("high")),
else => 0,
} else 0,
};
}
// -- Tests --
test "parseTimeSeriesResponse basic" {
const body =
\\{
\\ "meta": {"symbol": "AAPL"},
\\ "values": [
\\ {"datetime": "2024-01-03", "open": "187.15", "high": "188.44", "low": "183.89", "close": "184.25", "volume": "58414460"},
\\ {"datetime": "2024-01-02", "open": "185.00", "high": "186.10", "low": "184.00", "close": "185.50", "volume": "42000000"}
\\ ],
\\ "status": "ok"
\\}
;
const allocator = std.testing.allocator;
const candles = try parseTimeSeriesResponse(allocator, body);
defer allocator.free(candles);
// Should reverse to oldest-first
try std.testing.expectEqual(@as(usize, 2), candles.len);
// First candle should be 2024-01-02 (oldest)
try std.testing.expectEqual(@as(i16, 2024), candles[0].date.year());
try std.testing.expectEqual(@as(u8, 1), candles[0].date.month());
try std.testing.expectEqual(@as(u8, 2), candles[0].date.day());
try std.testing.expectApproxEqAbs(@as(f64, 185.0), candles[0].open, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 185.5), candles[0].close, 0.01);
try std.testing.expectEqual(@as(u64, 42000000), candles[0].volume);
// Second candle should be 2024-01-03 (newest)
try std.testing.expectEqual(@as(u8, 3), candles[1].date.day());
try std.testing.expectApproxEqAbs(@as(f64, 187.15), candles[1].open, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 184.25), candles[1].close, 0.01);
// adj_close should equal close (split-adjusted only)
try std.testing.expectApproxEqAbs(candles[0].close, candles[0].adj_close, 0.001);
}
test "parseTimeSeriesResponse with datetime timestamps" {
// Twelve Data can return "YYYY-MM-DD HH:MM:SS" format
const body =
\\{
\\ "values": [
\\ {"datetime": "2024-06-15 15:30:00", "open": "100.0", "high": "101.0", "low": "99.0", "close": "100.5", "volume": "1000000"}
\\ ],
\\ "status": "ok"
\\}
;
const allocator = std.testing.allocator;
const candles = try parseTimeSeriesResponse(allocator, body);
defer allocator.free(candles);
try std.testing.expectEqual(@as(usize, 1), candles.len);
try std.testing.expectEqual(@as(i16, 2024), candles[0].date.year());
try std.testing.expectEqual(@as(u8, 6), candles[0].date.month());
try std.testing.expectEqual(@as(u8, 15), candles[0].date.day());
}
test "parseTimeSeriesResponse error response" {
const body =
\\{
\\ "status": "error",
\\ "code": 400,
\\ "message": "Invalid symbol"
\\}
;
const allocator = std.testing.allocator;
const result = parseTimeSeriesResponse(allocator, body);
try std.testing.expectError(error.RequestFailed, result);
}
test "parseTimeSeriesResponse rate limited" {
const body =
\\{
\\ "status": "error",
\\ "code": 429,
\\ "message": "Too many requests"
\\}
;
const allocator = std.testing.allocator;
const result = parseTimeSeriesResponse(allocator, body);
try std.testing.expectError(error.RateLimited, result);
}
test "parseTimeSeriesResponse empty values" {
const body =
\\{
\\ "values": [],
\\ "status": "ok"
\\}
;
const allocator = std.testing.allocator;
const candles = try parseTimeSeriesResponse(allocator, body);
defer allocator.free(candles);
try std.testing.expectEqual(@as(usize, 0), candles.len);
}
test "parseQuoteResponse basic" {
const body =
\\{
\\ "symbol": "AAPL",
\\ "name": "Apple Inc",
\\ "exchange": "NASDAQ",
\\ "datetime": "2024-01-15",
\\ "open": "182.15",
\\ "high": "185.00",
\\ "low": "181.50",
\\ "close": "183.63",
\\ "volume": "65000000",
\\ "previous_close": "181.18",
\\ "change": "2.45",
\\ "percent_change": "1.35",
\\ "average_volume": "55000000",
\\ "fifty_two_week": {
\\ "low": "140.00",
\\ "high": "200.00"
\\ }
\\}
;
const allocator = std.testing.allocator;
const quote = try parseQuoteResponse(allocator, body, "AAPL");
try std.testing.expectApproxEqAbs(@as(f64, 183.63), quote.close, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 182.15), quote.open, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 185.0), quote.high, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 181.5), quote.low, 0.01);
try std.testing.expectEqual(@as(u64, 65000000), quote.volume);
try std.testing.expectApproxEqAbs(@as(f64, 181.18), quote.previous_close, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 2.45), quote.change, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1.35), quote.percent_change, 0.01);
try std.testing.expectEqual(@as(u64, 55000000), quote.average_volume);
try std.testing.expectApproxEqAbs(@as(f64, 140.0), quote.fifty_two_week_low, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 200.0), quote.fifty_two_week_high, 0.01);
}
test "parseQuoteResponse error response" {
const body =
\\{
\\ "status": "error",
\\ "code": 404,
\\ "message": "Symbol not found"
\\}
;
const allocator = std.testing.allocator;
const result = parseQuoteResponse(allocator, body, "BAD");
try std.testing.expectError(error.RequestFailed, result);
}
test "parseJsonFloat various formats" {
// String number
const str_val: std.json.Value = .{ .string = "42.5" };
try std.testing.expectApproxEqAbs(@as(f64, 42.5), parseJsonFloat(str_val), 0.001);
// Float
const float_val: std.json.Value = .{ .float = 3.14 };
try std.testing.expectApproxEqAbs(@as(f64, 3.14), parseJsonFloat(float_val), 0.001);
// Integer
const int_val: std.json.Value = .{ .integer = 100 };
try std.testing.expectApproxEqAbs(@as(f64, 100.0), parseJsonFloat(int_val), 0.001);
// Null
try std.testing.expectApproxEqAbs(@as(f64, 0.0), parseJsonFloat(null), 0.001);
// Invalid string
const bad_str: std.json.Value = .{ .string = "not_a_number" };
try std.testing.expectApproxEqAbs(@as(f64, 0.0), parseJsonFloat(bad_str), 0.001);
}