//! 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); }