From bbdf340de48bf9f9477ca7ca61000b9566860715 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 6 Mar 2026 14:52:06 -0800 Subject: [PATCH] add tests to providers/consolidate common json utilities --- src/providers/alphavantage.zig | 166 +++++++++++++++++++++++++--- src/providers/cboe.zig | 114 +++++++++++++------ src/providers/finnhub.zig | 171 +++++++++++++++++++++-------- src/providers/json_utils.zig | 61 +++++++++++ src/providers/openfigi.zig | 144 ++++++++++++++++++++++++ src/providers/polygon.zig | 161 ++++++++++++++++++++++----- src/providers/twelvedata.zig | 194 +++++++++++++++++++++++++++++++-- 7 files changed, 877 insertions(+), 134 deletions(-) create mode 100644 src/providers/json_utils.zig diff --git a/src/providers/alphavantage.zig b/src/providers/alphavantage.zig index 2a58e58..f23dbbe 100644 --- a/src/providers/alphavantage.zig +++ b/src/providers/alphavantage.zig @@ -14,6 +14,9 @@ const EtfProfile = @import("../models/etf_profile.zig").EtfProfile; const Holding = @import("../models/etf_profile.zig").Holding; const SectorWeight = @import("../models/etf_profile.zig").SectorWeight; const provider = @import("provider.zig"); +const json_utils = @import("json_utils.zig"); +const jsonStr = json_utils.jsonStr; +const mapHttpError = json_utils.mapHttpError; const base_url = "https://www.alphavantage.co/query"; @@ -28,6 +31,152 @@ pub const CompanyOverview = struct { asset_type: ?[]const u8 = null, }; +// -- Tests -- + +test "parseEtfProfileResponse basic" { + const body = + \\{ + \\ "net_assets": "323000000000", + \\ "net_expense_ratio": "0.03", + \\ "portfolio_turnover": "4.00", + \\ "dividend_yield": "1.25", + \\ "inception_date": "2010-09-09", + \\ "leveraged": "NO", + \\ "sectors": [ + \\ {"sector": "Technology", "weight": "31.50"}, + \\ {"sector": "Healthcare", "weight": "12.80"} + \\ ], + \\ "holdings": [ + \\ {"symbol": "AAPL", "description": "Apple Inc", "weight": "7.10"}, + \\ {"symbol": "MSFT", "description": "Microsoft Corp", "weight": "6.50"} + \\ ] + \\} + ; + + const allocator = std.testing.allocator; + const profile = try parseEtfProfileResponse(allocator, body, "VTI"); + + // Clean up allocated slices + defer { + if (profile.sectors) |sectors| { + for (sectors) |s| allocator.free(s.name); + allocator.free(sectors); + } + if (profile.holdings) |holdings| { + for (holdings) |h| { + if (h.symbol) |s| allocator.free(s); + allocator.free(h.name); + } + allocator.free(holdings); + } + } + + try std.testing.expectEqualStrings("VTI", profile.symbol); + try std.testing.expectApproxEqAbs(@as(f64, 323000000000), profile.net_assets.?, 1.0); + try std.testing.expectApproxEqAbs(@as(f64, 0.03), profile.expense_ratio.?, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 4.0), profile.portfolio_turnover.?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 1.25), profile.dividend_yield.?, 0.01); + try std.testing.expect(profile.inception_date != null); + try std.testing.expect(!profile.leveraged); + + try std.testing.expectEqual(@as(usize, 2), profile.sectors.?.len); + try std.testing.expectEqualStrings("Technology", profile.sectors.?[0].name); + try std.testing.expectApproxEqAbs(@as(f64, 31.50), profile.sectors.?[0].weight, 0.01); + + try std.testing.expectEqual(@as(usize, 2), profile.holdings.?.len); + try std.testing.expectEqualStrings("AAPL", profile.holdings.?[0].symbol.?); + try std.testing.expectEqualStrings("Apple Inc", profile.holdings.?[0].name); + try std.testing.expectEqual(@as(u32, 2), profile.total_holdings.?); +} + +test "parseEtfProfileResponse leveraged ETF" { + const body = + \\{ + \\ "net_assets": "5000000000", + \\ "leveraged": "YES", + \\ "sectors": [], + \\ "holdings": [] + \\} + ; + + const allocator = std.testing.allocator; + const profile = try parseEtfProfileResponse(allocator, body, "TQQQ"); + defer { + if (profile.sectors) |s| allocator.free(s); + if (profile.holdings) |h| allocator.free(h); + } + + try std.testing.expect(profile.leveraged); +} + +test "parseEtfProfileResponse error response" { + const body = + \\{"Error Message": "Invalid API call"} + ; + + const allocator = std.testing.allocator; + const result = parseEtfProfileResponse(allocator, body, "BAD"); + try std.testing.expectError(provider.ProviderError.RequestFailed, result); +} + +test "parseEtfProfileResponse rate limited" { + const body = + \\{"Note": "Thank you for using Alpha Vantage! Please visit..."} + ; + + const allocator = std.testing.allocator; + const result = parseEtfProfileResponse(allocator, body, "SPY"); + try std.testing.expectError(provider.ProviderError.RateLimited, result); +} + +test "parseCompanyOverview basic" { + const body = + \\{ + \\ "Symbol": "AAPL", + \\ "Name": "Apple Inc", + \\ "Sector": "Technology", + \\ "Industry": "Consumer Electronics", + \\ "Country": "USA", + \\ "MarketCapitalization": "2900000000000", + \\ "AssetType": "Common Stock" + \\} + ; + + const allocator = std.testing.allocator; + const overview = try parseCompanyOverview(allocator, body, "AAPL"); + defer { + if (overview.name) |n| allocator.free(n); + if (overview.sector) |s| allocator.free(s); + if (overview.industry) |i| allocator.free(i); + if (overview.country) |c| allocator.free(c); + if (overview.market_cap) |m| allocator.free(m); + if (overview.asset_type) |a| allocator.free(a); + } + + try std.testing.expectEqualStrings("AAPL", overview.symbol); + try std.testing.expectEqualStrings("Apple Inc", overview.name.?); + try std.testing.expectEqualStrings("Technology", overview.sector.?); + try std.testing.expectEqualStrings("Consumer Electronics", overview.industry.?); + try std.testing.expectEqualStrings("USA", overview.country.?); + try std.testing.expectEqualStrings("2900000000000", overview.market_cap.?); + try std.testing.expectEqualStrings("Common Stock", overview.asset_type.?); +} + +test "parseCompanyOverview missing fields" { + const body = + \\{ + \\ "Symbol": "XYZ" + \\} + ; + + const allocator = std.testing.allocator; + const overview = try parseCompanyOverview(allocator, body, "XYZ"); + + try std.testing.expect(overview.name == null); + try std.testing.expect(overview.sector == null); + try std.testing.expect(overview.industry == null); +} + pub const AlphaVantage = struct { api_key: []const u8, client: http.Client, @@ -230,23 +379,6 @@ fn parseStrFloat(val: ?std.json.Value) ?f64 { }; } -fn jsonStr(val: ?std.json.Value) ?[]const u8 { - const v = val orelse return null; - return switch (v) { - .string => |s| s, - else => null, - }; -} - -fn mapHttpError(err: http.HttpError) provider.ProviderError { - return switch (err) { - error.RateLimited => provider.ProviderError.RateLimited, - error.Unauthorized => provider.ProviderError.Unauthorized, - error.NotFound => provider.ProviderError.NotFound, - else => provider.ProviderError.RequestFailed, - }; -} - fn parseCompanyOverview( allocator: std.mem.Allocator, body: []const u8, diff --git a/src/providers/cboe.zig b/src/providers/cboe.zig index ab997b8..bb09e97 100644 --- a/src/providers/cboe.zig +++ b/src/providers/cboe.zig @@ -12,6 +12,10 @@ const OptionContract = @import("../models/option.zig").OptionContract; const OptionsChain = @import("../models/option.zig").OptionsChain; const ContractType = @import("../models/option.zig").ContractType; const provider = @import("provider.zig"); +const json_utils = @import("json_utils.zig"); +const optFloat = json_utils.optFloat; +const optUint = json_utils.optUint; +const mapHttpError = json_utils.mapHttpError; const base_url = "https://cdn.cboe.com/api/global/delayed_quotes/options"; @@ -226,17 +230,26 @@ const ExpMap = struct { var chains = allocator.alloc(OptionsChain, self.entries.items.len) catch return provider.ProviderError.OutOfMemory; - errdefer allocator.free(chains); + + var initialized: usize = 0; + errdefer { + for (chains[0..initialized]) |c| { + allocator.free(c.underlying_symbol); + allocator.free(c.calls); + allocator.free(c.puts); + } + allocator.free(chains); + } for (self.entries.items, 0..) |*entry, i| { const owned_symbol = allocator.dupe(u8, symbol) catch return provider.ProviderError.OutOfMemory; + errdefer allocator.free(owned_symbol); const calls = entry.calls.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; - const puts = entry.puts.toOwnedSlice(allocator) catch { - allocator.free(calls); + errdefer allocator.free(calls); + const puts = entry.puts.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; - }; chains[i] = .{ .underlying_symbol = owned_symbol, @@ -245,6 +258,7 @@ const ExpMap = struct { .calls = calls, .puts = puts, }; + initialized += 1; } self.entries.deinit(allocator); @@ -253,37 +267,6 @@ const ExpMap = struct { } }; -// ── JSON helpers ──────────────────────────────────────────────────── - -fn optFloat(val: ?std.json.Value) ?f64 { - const v = val orelse return null; - return switch (v) { - .float => |f| f, - .integer => |i| @floatFromInt(i), - .null => null, - else => null, - }; -} - -fn optUint(val: ?std.json.Value) ?u64 { - const v = val orelse return null; - return switch (v) { - .integer => |i| if (i >= 0) @intCast(i) else null, - .float => |f| if (f >= 0 and f == @floor(f)) @intFromFloat(f) else null, - .null => null, - else => null, - }; -} - -fn mapHttpError(err: http.HttpError) provider.ProviderError { - return switch (err) { - error.RateLimited => provider.ProviderError.RateLimited, - error.Unauthorized => provider.ProviderError.Unauthorized, - error.NotFound => provider.ProviderError.NotFound, - else => provider.ProviderError.RequestFailed, - }; -} - // ── Tests ─────────────────────────────────────────────────────────── test "parseOccSymbol -- call" { @@ -309,3 +292,64 @@ test "parseOccSymbol -- invalid" { try std.testing.expect(parseOccSymbol("X", 1) == null); try std.testing.expect(parseOccSymbol("AAPL26022", 4) == null); } + +test "parseResponse basic" { + const body = + \\{ + \\ "data": { + \\ "current_price": 185.50, + \\ "options": [ + \\ { + \\ "option": "AAPL260320C00180000", + \\ "bid": 7.50, + \\ "ask": 7.80, + \\ "last_trade_price": 7.65, + \\ "volume": 1234, + \\ "open_interest": 5678, + \\ "iv": 0.25, + \\ "delta": 0.65 + \\ }, + \\ { + \\ "option": "AAPL260320P00180000", + \\ "bid": 2.10, + \\ "ask": 2.30 + \\ } + \\ ] + \\ } + \\} + ; + + const allocator = std.testing.allocator; + const chains = try parseResponse(allocator, body, "AAPL"); + defer { + for (chains) |chain| { + allocator.free(chain.underlying_symbol); + allocator.free(chain.calls); + allocator.free(chain.puts); + } + allocator.free(chains); + } + + try std.testing.expectEqual(@as(usize, 1), chains.len); + try std.testing.expectApproxEqAbs(@as(f64, 185.50), chains[0].underlying_price.?, 0.01); + try std.testing.expect(chains[0].expiration.eql(Date.fromYmd(2026, 3, 20))); + + try std.testing.expectEqual(@as(usize, 1), chains[0].calls.len); + try std.testing.expectApproxEqAbs(@as(f64, 180.0), chains[0].calls[0].strike, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 7.50), chains[0].calls[0].bid.?, 0.01); + try std.testing.expectEqual(@as(?u64, 1234), chains[0].calls[0].volume); + try std.testing.expectApproxEqAbs(@as(f64, 0.25), chains[0].calls[0].implied_volatility.?, 0.01); + + try std.testing.expectEqual(@as(usize, 1), chains[0].puts.len); + try std.testing.expectApproxEqAbs(@as(f64, 2.10), chains[0].puts[0].bid.?, 0.01); +} + +test "parseResponse missing data" { + const body = + \\{"data": {}} + ; + + const allocator = std.testing.allocator; + const result = parseResponse(allocator, body, "AAPL"); + try std.testing.expectError(provider.ProviderError.ParseError, result); +} diff --git a/src/providers/finnhub.zig b/src/providers/finnhub.zig index 8e1c7ab..02fdf04 100644 --- a/src/providers/finnhub.zig +++ b/src/providers/finnhub.zig @@ -19,6 +19,12 @@ const ContractType = @import("../models/option.zig").ContractType; const EarningsEvent = @import("../models/earnings.zig").EarningsEvent; const ReportTime = @import("../models/earnings.zig").ReportTime; const provider = @import("provider.zig"); +const json_utils = @import("json_utils.zig"); +const parseJsonFloat = json_utils.parseJsonFloat; +const optFloat = json_utils.optFloat; +const optUint = json_utils.optUint; +const jsonStr = json_utils.jsonStr; +const mapHttpError = json_utils.mapHttpError; const base_url = "https://finnhub.io/api/v1"; @@ -163,8 +169,11 @@ pub const Finnhub = struct { if (chain) |c| { // Merge calls and puts into a single slice const total = c.calls.len + c.puts.len; - const merged = allocator.alloc(OptionContract, total) catch + const merged = allocator.alloc(OptionContract, total) catch { + allocator.free(c.calls); + allocator.free(c.puts); return provider.ProviderError.OutOfMemory; + }; @memcpy(merged[0..c.calls.len], c.calls); @memcpy(merged[c.calls.len..], c.puts); allocator.free(c.calls); @@ -175,6 +184,10 @@ pub const Finnhub = struct { } // No expiration given: return contracts from nearest expiration const chains = try ptr.fetchOptionsChain(allocator, symbol); + if (chains.len == 0) { + allocator.free(chains); + return allocator.alloc(OptionContract, 0) catch return provider.ProviderError.OutOfMemory; + } defer { for (chains[1..]) |chain| { allocator.free(chain.calls); @@ -182,7 +195,6 @@ pub const Finnhub = struct { } allocator.free(chains); } - if (chains.len == 0) return allocator.alloc(OptionContract, 0) catch return provider.ProviderError.OutOfMemory; const first = chains[0]; const total = first.calls.len + first.puts.len; const merged = allocator.alloc(OptionContract, total) catch @@ -389,43 +401,6 @@ fn parseEarningsResponse( // -- Helpers -- -fn parseJsonFloat(val: std.json.Value) f64 { - return switch (val) { - .float => |f| f, - .integer => |i| @floatFromInt(i), - .string => |s| std.fmt.parseFloat(f64, s) catch 0, - else => 0, - }; -} - -fn optFloat(val: ?std.json.Value) ?f64 { - const v = val orelse return null; - return switch (v) { - .float => |f| f, - .integer => |i| @floatFromInt(i), - .null => null, - else => null, - }; -} - -fn optUint(val: ?std.json.Value) ?u64 { - const v = val orelse return null; - return switch (v) { - .integer => |i| if (i >= 0) @intCast(i) else null, - .float => |f| if (f >= 0) @intFromFloat(f) else null, - .null => null, - else => null, - }; -} - -fn jsonStr(val: ?std.json.Value) ?[]const u8 { - const v = val orelse return null; - return switch (v) { - .string => |s| s, - else => null, - }; -} - fn parseQuarter(val: ?std.json.Value) ?u8 { const v = val orelse return null; const i = switch (v) { @@ -454,11 +429,115 @@ fn parseReportTime(val: ?std.json.Value) ReportTime { return .unknown; } -fn mapHttpError(err: http.HttpError) provider.ProviderError { - return switch (err) { - error.RateLimited => provider.ProviderError.RateLimited, - error.Unauthorized => provider.ProviderError.Unauthorized, - error.NotFound => provider.ProviderError.NotFound, - else => provider.ProviderError.RequestFailed, - }; +// -- Tests -- + +test "parseEarningsResponse basic" { + const body = + \\{ + \\ "earningsCalendar": [ + \\ { + \\ "date": "2024-10-31", + \\ "epsActual": 1.64, + \\ "epsEstimate": 1.60, + \\ "quarter": 4, + \\ "year": 2024, + \\ "revenueActual": 94930000000, + \\ "revenueEstimate": 94360000000, + \\ "hour": "amc" + \\ }, + \\ { + \\ "date": "2025-04-15", + \\ "epsEstimate": 1.70, + \\ "quarter": 1, + \\ "year": 2025, + \\ "hour": "bmo" + \\ } + \\ ] + \\} + ; + + const allocator = std.testing.allocator; + const events = try parseEarningsResponse(allocator, body, "AAPL"); + defer allocator.free(events); + + try std.testing.expectEqual(@as(usize, 2), events.len); + + // Past earnings with actual + try std.testing.expect(events[0].date.eql(Date.fromYmd(2024, 10, 31))); + try std.testing.expectApproxEqAbs(@as(f64, 1.64), events[0].actual.?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 1.60), events[0].estimate.?, 0.01); + try std.testing.expect(events[0].surprise != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.04), events[0].surprise.?, 0.01); + try std.testing.expectEqual(@as(?u8, 4), events[0].quarter); + try std.testing.expectEqual(@as(?i16, 2024), events[0].fiscal_year); + try std.testing.expectEqual(ReportTime.amc, events[0].report_time); + + // Future earnings without actual + try std.testing.expect(events[1].actual == null); + try std.testing.expect(events[1].surprise == null); + try std.testing.expectEqual(ReportTime.bmo, events[1].report_time); +} + +test "parseEarningsResponse error" { + const body = + \\{"error": "API limit reached"} + ; + + const allocator = std.testing.allocator; + const result = parseEarningsResponse(allocator, body, "AAPL"); + try std.testing.expectError(provider.ProviderError.RequestFailed, result); +} + +test "parseEarningsResponse empty" { + const body = + \\{"earningsCalendar": []} + ; + + const allocator = std.testing.allocator; + const events = try parseEarningsResponse(allocator, body, "AAPL"); + defer allocator.free(events); + try std.testing.expectEqual(@as(usize, 0), events.len); +} + +test "parseOptionsResponse basic" { + const body = + \\{ + \\ "lastTradePrice": 185.50, + \\ "data": [ + \\ { + \\ "expirationDate": "2026-03-20", + \\ "options": { + \\ "CALL": [ + \\ {"strike": 180.0, "bid": 7.50, "ask": 7.80, "volume": 1234, "openInterest": 5678} + \\ ], + \\ "PUT": [ + \\ {"strike": 180.0, "bid": 2.10, "ask": 2.30} + \\ ] + \\ } + \\ } + \\ ] + \\} + ; + + const allocator = std.testing.allocator; + const chains = try parseOptionsResponse(allocator, body, "AAPL"); + defer { + for (chains) |chain| { + allocator.free(chain.calls); + allocator.free(chain.puts); + } + allocator.free(chains); + } + + try std.testing.expectEqual(@as(usize, 1), chains.len); + try std.testing.expectApproxEqAbs(@as(f64, 185.50), chains[0].underlying_price.?, 0.01); + try std.testing.expect(chains[0].expiration.eql(Date.fromYmd(2026, 3, 20))); + + try std.testing.expectEqual(@as(usize, 1), chains[0].calls.len); + try std.testing.expectApproxEqAbs(@as(f64, 180.0), chains[0].calls[0].strike, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 7.50), chains[0].calls[0].bid.?, 0.01); + try std.testing.expectEqual(@as(?u64, 1234), chains[0].calls[0].volume); + + try std.testing.expectEqual(@as(usize, 1), chains[0].puts.len); + try std.testing.expectApproxEqAbs(@as(f64, 2.10), chains[0].puts[0].bid.?, 0.01); } diff --git a/src/providers/json_utils.zig b/src/providers/json_utils.zig new file mode 100644 index 0000000..c06b08c --- /dev/null +++ b/src/providers/json_utils.zig @@ -0,0 +1,61 @@ +//! Shared JSON parsing helpers used by all API providers. +//! Centralises the common patterns: extracting floats, strings, +//! unsigned ints, and mapping HTTP errors to provider errors. + +const std = @import("std"); +const http = @import("../net/http.zig"); +const provider = @import("provider.zig"); + +/// Extract a required float from a JSON value (string, float, or integer). +/// Returns 0 for null, missing, or unparseable values. +pub fn parseJsonFloat(val: ?std.json.Value) f64 { + const v = val orelse return 0; + return switch (v) { + .float => |f| f, + .integer => |i| @floatFromInt(i), + .string => |s| std.fmt.parseFloat(f64, s) catch 0, + else => 0, + }; +} + +/// Extract an optional float. Returns null for missing/null JSON values. +pub fn optFloat(val: ?std.json.Value) ?f64 { + const v = val orelse return null; + return switch (v) { + .float => |f| f, + .integer => |i| @floatFromInt(i), + .null => null, + else => null, + }; +} + +/// Extract an optional unsigned integer. Returns null for missing/null/negative values. +/// Float values are accepted only if they are whole numbers (e.g. 1234.0). +pub fn optUint(val: ?std.json.Value) ?u64 { + const v = val orelse return null; + return switch (v) { + .integer => |i| if (i >= 0) @intCast(i) else null, + .float => |f| if (f >= 0 and f == @floor(f)) @intFromFloat(f) else null, + .null => null, + else => null, + }; +} + +/// Extract an optional string. Returns null for missing or non-string values. +pub fn jsonStr(val: ?std.json.Value) ?[]const u8 { + const v = val orelse return null; + return switch (v) { + .string => |s| s, + else => null, + }; +} + +/// Map an HTTP-level error to the corresponding provider error. +pub fn mapHttpError(err: http.HttpError) provider.ProviderError { + return switch (err) { + error.RateLimited => provider.ProviderError.RateLimited, + error.Unauthorized => provider.ProviderError.Unauthorized, + error.NotFound => provider.ProviderError.NotFound, + else => provider.ProviderError.RequestFailed, + }; +} diff --git a/src/providers/openfigi.zig b/src/providers/openfigi.zig index 3f58977..9eb3681 100644 --- a/src/providers/openfigi.zig +++ b/src/providers/openfigi.zig @@ -240,3 +240,147 @@ fn parseResponse(allocator: std.mem.Allocator, body: []const u8, expected_count: return results; } + +// -- Tests -- + +test "parseResponse basic single CUSIP" { + const allocator = std.testing.allocator; + + // parseResponse takes ownership of body (frees it), so we must dupe + const body = try allocator.dupe(u8, + \\[ + \\ { + \\ "data": [ + \\ {"ticker": "AAPL", "name": "APPLE INC", "securityType": "Common Stock", "exchCode": "US"} + \\ ] + \\ } + \\] + ); + + const results = try parseResponse(allocator, body, 1); + defer { + for (results) |r| { + if (r.ticker) |t| allocator.free(t); + if (r.name) |n| allocator.free(n); + if (r.security_type) |s| allocator.free(s); + } + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expect(results[0].found); + try std.testing.expectEqualStrings("AAPL", results[0].ticker.?); + try std.testing.expectEqualStrings("APPLE INC", results[0].name.?); + try std.testing.expectEqualStrings("Common Stock", results[0].security_type.?); +} + +test "parseResponse prefers US exchange" { + const allocator = std.testing.allocator; + + const body = try allocator.dupe(u8, + \\[ + \\ { + \\ "data": [ + \\ {"ticker": "AAPL-DE", "name": "APPLE INC", "securityType": "Common Stock", "exchCode": "GY"}, + \\ {"ticker": "AAPL", "name": "APPLE INC", "securityType": "Common Stock", "exchCode": "US"} + \\ ] + \\ } + \\] + ); + + const results = try parseResponse(allocator, body, 1); + defer { + for (results) |r| { + if (r.ticker) |t| allocator.free(t); + if (r.name) |n| allocator.free(n); + if (r.security_type) |s| allocator.free(s); + } + allocator.free(results); + } + + try std.testing.expectEqualStrings("AAPL", results[0].ticker.?); +} + +test "parseResponse warning (no match)" { + const allocator = std.testing.allocator; + + const body = try allocator.dupe(u8, + \\[ + \\ { + \\ "warning": "No identifier found." + \\ } + \\] + ); + + const results = try parseResponse(allocator, body, 1); + defer allocator.free(results); + + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expect(results[0].found); // API responded, just no match + try std.testing.expect(results[0].ticker == null); +} + +test "parseResponse multiple CUSIPs" { + const allocator = std.testing.allocator; + + const body = try allocator.dupe(u8, + \\[ + \\ { + \\ "data": [ + \\ {"ticker": "AAPL", "name": "APPLE INC", "securityType": "Common Stock", "exchCode": "US"} + \\ ] + \\ }, + \\ { + \\ "warning": "No identifier found." + \\ }, + \\ { + \\ "data": [ + \\ {"ticker": "MSFT", "name": "MICROSOFT CORP", "securityType": "Common Stock", "exchCode": "US"} + \\ ] + \\ } + \\] + ); + + const results = try parseResponse(allocator, body, 3); + defer { + for (results) |r| { + if (r.ticker) |t| allocator.free(t); + if (r.name) |n| allocator.free(n); + if (r.security_type) |s| allocator.free(s); + } + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 3), results.len); + + // First: AAPL + try std.testing.expect(results[0].found); + try std.testing.expectEqualStrings("AAPL", results[0].ticker.?); + + // Second: no match + try std.testing.expect(results[1].found); + try std.testing.expect(results[1].ticker == null); + + // Third: MSFT + try std.testing.expect(results[2].found); + try std.testing.expectEqualStrings("MSFT", results[2].ticker.?); +} + +test "parseResponse empty data array" { + const allocator = std.testing.allocator; + + const body = try allocator.dupe(u8, + \\[ + \\ { + \\ "data": [] + \\ } + \\] + ); + + const results = try parseResponse(allocator, body, 1); + defer allocator.free(results); + + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expect(results[0].found); + try std.testing.expect(results[0].ticker == null); +} diff --git a/src/providers/polygon.zig b/src/providers/polygon.zig index c2fc1ec..7d3e330 100644 --- a/src/providers/polygon.zig +++ b/src/providers/polygon.zig @@ -14,6 +14,10 @@ const Dividend = @import("../models/dividend.zig").Dividend; const DividendType = @import("../models/dividend.zig").DividendType; const Split = @import("../models/split.zig").Split; const provider = @import("provider.zig"); +const json_utils = @import("json_utils.zig"); +const parseJsonFloat = json_utils.parseJsonFloat; +const jsonStr = json_utils.jsonStr; +const mapHttpError = json_utils.mapHttpError; const base_url = "https://api.polygon.io"; @@ -381,24 +385,6 @@ fn appendApiKey(allocator: std.mem.Allocator, url: []const u8, api_key: []const return std.fmt.allocPrint(allocator, "{s}{c}apiKey={s}", .{ url, sep, api_key }); } -fn parseJsonFloat(val: ?std.json.Value) f64 { - const v = val orelse return 0; - return switch (v) { - .float => |f| f, - .integer => |i| @floatFromInt(i), - .string => |s| std.fmt.parseFloat(f64, s) catch 0, - else => 0, - }; -} - -fn jsonStr(val: ?std.json.Value) ?[]const u8 { - const v = val orelse return null; - return switch (v) { - .string => |s| s, - else => null, - }; -} - fn parseDateField(obj: std.json.ObjectMap, key: []const u8) ?Date { const v = obj.get(key) orelse return null; const s = switch (v) { @@ -429,11 +415,136 @@ fn parseDividendType(obj: std.json.ObjectMap) DividendType { return .unknown; } -fn mapHttpError(err: http.HttpError) provider.ProviderError { - return switch (err) { - error.RateLimited => provider.ProviderError.RateLimited, - error.Unauthorized => provider.ProviderError.Unauthorized, - error.NotFound => provider.ProviderError.NotFound, - else => provider.ProviderError.RequestFailed, - }; +// -- Tests -- + +test "parseDividendsPage basic" { + const body = + \\{ + \\ "status": "OK", + \\ "results": [ + \\ { + \\ "ex_dividend_date": "2024-08-12", + \\ "cash_amount": 0.25, + \\ "pay_date": "2024-08-15", + \\ "record_date": "2024-08-13", + \\ "frequency": 4, + \\ "dividend_type": "CD", + \\ "currency": "USD" + \\ }, + \\ { + \\ "ex_dividend_date": "2024-05-10", + \\ "cash_amount": 0.25, + \\ "dividend_type": "SC" + \\ } + \\ ] + \\} + ; + + const allocator = std.testing.allocator; + var out = std.ArrayList(Dividend).empty; + defer out.deinit(allocator); + + const next_url = try parseDividendsPage(allocator, body, &out); + try std.testing.expect(next_url == null); + + try std.testing.expectEqual(@as(usize, 2), out.items.len); + + try std.testing.expect(out.items[0].ex_date.eql(Date.fromYmd(2024, 8, 12))); + try std.testing.expectApproxEqAbs(@as(f64, 0.25), out.items[0].amount, 0.001); + try std.testing.expect(out.items[0].pay_date != null); + try std.testing.expectEqual(@as(?u8, 4), out.items[0].frequency); + try std.testing.expectEqual(DividendType.regular, out.items[0].type); + + try std.testing.expectEqual(DividendType.special, out.items[1].type); +} + +test "parseDividendsPage with pagination" { + const body = + \\{ + \\ "status": "OK", + \\ "results": [ + \\ {"ex_dividend_date": "2024-01-10", "cash_amount": 0.50} + \\ ], + \\ "next_url": "https://api.polygon.io/v3/reference/dividends?cursor=abc123" + \\} + ; + + const allocator = std.testing.allocator; + var out = std.ArrayList(Dividend).empty; + defer out.deinit(allocator); + + const next_url = try parseDividendsPage(allocator, body, &out); + try std.testing.expect(next_url != null); + defer allocator.free(next_url.?); + + try std.testing.expectEqualStrings("https://api.polygon.io/v3/reference/dividends?cursor=abc123", next_url.?); + try std.testing.expectEqual(@as(usize, 1), out.items.len); +} + +test "parseDividendsPage error status" { + const body = + \\{"status": "ERROR", "error": "Bad request"} + ; + + const allocator = std.testing.allocator; + var out = std.ArrayList(Dividend).empty; + defer out.deinit(allocator); + + const result = parseDividendsPage(allocator, body, &out); + try std.testing.expectError(provider.ProviderError.RequestFailed, result); +} + +test "parseSplitsResponse basic" { + const body = + \\{ + \\ "status": "OK", + \\ "results": [ + \\ {"execution_date": "2020-08-31", "split_to": 4, "split_from": 1}, + \\ {"execution_date": "2014-06-09", "split_to": 7, "split_from": 1} + \\ ] + \\} + ; + + const allocator = std.testing.allocator; + const splits = try parseSplitsResponse(allocator, body); + defer allocator.free(splits); + + try std.testing.expectEqual(@as(usize, 2), splits.len); + try std.testing.expect(splits[0].date.eql(Date.fromYmd(2020, 8, 31))); + try std.testing.expectApproxEqAbs(@as(f64, 4.0), splits[0].numerator, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 1.0), splits[0].denominator, 0.001); +} + +test "parseSplitsResponse empty results" { + const body = + \\{"status": "OK", "results": []} + ; + + const allocator = std.testing.allocator; + const splits = try parseSplitsResponse(allocator, body); + defer allocator.free(splits); + + try std.testing.expectEqual(@as(usize, 0), splits.len); +} + +test "parseCandlesResponse basic" { + const body = + \\{ + \\ "status": "OK", + \\ "results": [ + \\ {"t": 1704067200000, "o": 185.5, "h": 186.2, "l": 184.1, "c": 185.8, "v": 52000000} + \\ ] + \\} + ; + + const allocator = std.testing.allocator; + const candles = try parseCandlesResponse(allocator, body); + defer allocator.free(candles); + + try std.testing.expectEqual(@as(usize, 1), candles.len); + try std.testing.expectApproxEqAbs(@as(f64, 185.5), candles[0].open, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 186.2), candles[0].high, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 185.8), candles[0].close, 0.01); + try std.testing.expectApproxEqAbs(candles[0].close, candles[0].adj_close, 0.01); + try std.testing.expectEqual(@as(u64, 52000000), candles[0].volume); } diff --git a/src/providers/twelvedata.zig b/src/providers/twelvedata.zig index 1adf9d9..228f133 100644 --- a/src/providers/twelvedata.zig +++ b/src/providers/twelvedata.zig @@ -13,6 +13,8 @@ const RateLimiter = @import("../net/RateLimiter.zig"); const Date = @import("../models/date.zig").Date; const Candle = @import("../models/candle.zig").Candle; const provider = @import("provider.zig"); +const json_utils = @import("json_utils.zig"); +const parseJsonFloat = json_utils.parseJsonFloat; const base_url = "https://api.twelvedata.com"; @@ -291,17 +293,7 @@ fn parseQuoteBody(allocator: std.mem.Allocator, body: []const u8) provider.Provi return .{ .parsed = parsed }; } -/// Parse a JSON value that may be a string containing a number, or a number directly. -fn parseJsonFloat(val: ?std.json.Value) f64 { - const v = val orelse return 0; - return switch (v) { - .string => |s| std.fmt.parseFloat(f64, s) catch 0, - .float => |f| f, - .integer => |i| @floatFromInt(i), - else => 0, - }; -} - +/// TwelveData-specific: returns empty string instead of null for quote display convenience. fn jsonStr(val: ?std.json.Value) []const u8 { const v = val orelse return ""; return switch (v) { @@ -309,3 +301,183 @@ fn jsonStr(val: ?std.json.Value) []const u8 { else => "", }; } + +// -- 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 "parseQuoteBody 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; + var quote = try parseQuoteBody(allocator, body); + defer quote.deinit(); + + try std.testing.expectEqualStrings("AAPL", quote.symbol()); + try std.testing.expectEqualStrings("Apple Inc", quote.name()); + try std.testing.expectEqualStrings("NASDAQ", quote.exchange()); + 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 "parseQuoteBody error response" { + const body = + \\{ + \\ "status": "error", + \\ "code": 404, + \\ "message": "Symbol not found" + \\} + ; + + const allocator = std.testing.allocator; + const result = parseQuoteBody(allocator, body); + 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); +}