383 lines
13 KiB
Zig
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);
|
|
}
|