add tests to providers/consolidate common json utilities

This commit is contained in:
Emil Lerch 2026-03-06 14:52:06 -08:00
parent f1e9321bdc
commit bbdf340de4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 877 additions and 134 deletions

View file

@ -14,6 +14,9 @@ const EtfProfile = @import("../models/etf_profile.zig").EtfProfile;
const Holding = @import("../models/etf_profile.zig").Holding; const Holding = @import("../models/etf_profile.zig").Holding;
const SectorWeight = @import("../models/etf_profile.zig").SectorWeight; const SectorWeight = @import("../models/etf_profile.zig").SectorWeight;
const provider = @import("provider.zig"); 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"; const base_url = "https://www.alphavantage.co/query";
@ -28,6 +31,152 @@ pub const CompanyOverview = struct {
asset_type: ?[]const u8 = null, 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 { pub const AlphaVantage = struct {
api_key: []const u8, api_key: []const u8,
client: http.Client, 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( fn parseCompanyOverview(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
body: []const u8, body: []const u8,

View file

@ -12,6 +12,10 @@ const OptionContract = @import("../models/option.zig").OptionContract;
const OptionsChain = @import("../models/option.zig").OptionsChain; const OptionsChain = @import("../models/option.zig").OptionsChain;
const ContractType = @import("../models/option.zig").ContractType; const ContractType = @import("../models/option.zig").ContractType;
const provider = @import("provider.zig"); 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"; 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 var chains = allocator.alloc(OptionsChain, self.entries.items.len) catch
return provider.ProviderError.OutOfMemory; 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| { for (self.entries.items, 0..) |*entry, i| {
const owned_symbol = allocator.dupe(u8, symbol) catch const owned_symbol = allocator.dupe(u8, symbol) catch
return provider.ProviderError.OutOfMemory; return provider.ProviderError.OutOfMemory;
errdefer allocator.free(owned_symbol);
const calls = entry.calls.toOwnedSlice(allocator) catch const calls = entry.calls.toOwnedSlice(allocator) catch
return provider.ProviderError.OutOfMemory; return provider.ProviderError.OutOfMemory;
const puts = entry.puts.toOwnedSlice(allocator) catch { errdefer allocator.free(calls);
allocator.free(calls); const puts = entry.puts.toOwnedSlice(allocator) catch
return provider.ProviderError.OutOfMemory; return provider.ProviderError.OutOfMemory;
};
chains[i] = .{ chains[i] = .{
.underlying_symbol = owned_symbol, .underlying_symbol = owned_symbol,
@ -245,6 +258,7 @@ const ExpMap = struct {
.calls = calls, .calls = calls,
.puts = puts, .puts = puts,
}; };
initialized += 1;
} }
self.entries.deinit(allocator); 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 // Tests
test "parseOccSymbol -- call" { test "parseOccSymbol -- call" {
@ -309,3 +292,64 @@ test "parseOccSymbol -- invalid" {
try std.testing.expect(parseOccSymbol("X", 1) == null); try std.testing.expect(parseOccSymbol("X", 1) == null);
try std.testing.expect(parseOccSymbol("AAPL26022", 4) == 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);
}

View file

@ -19,6 +19,12 @@ const ContractType = @import("../models/option.zig").ContractType;
const EarningsEvent = @import("../models/earnings.zig").EarningsEvent; const EarningsEvent = @import("../models/earnings.zig").EarningsEvent;
const ReportTime = @import("../models/earnings.zig").ReportTime; const ReportTime = @import("../models/earnings.zig").ReportTime;
const provider = @import("provider.zig"); 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"; const base_url = "https://finnhub.io/api/v1";
@ -163,8 +169,11 @@ pub const Finnhub = struct {
if (chain) |c| { if (chain) |c| {
// Merge calls and puts into a single slice // Merge calls and puts into a single slice
const total = c.calls.len + c.puts.len; 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; return provider.ProviderError.OutOfMemory;
};
@memcpy(merged[0..c.calls.len], c.calls); @memcpy(merged[0..c.calls.len], c.calls);
@memcpy(merged[c.calls.len..], c.puts); @memcpy(merged[c.calls.len..], c.puts);
allocator.free(c.calls); allocator.free(c.calls);
@ -175,6 +184,10 @@ pub const Finnhub = struct {
} }
// No expiration given: return contracts from nearest expiration // No expiration given: return contracts from nearest expiration
const chains = try ptr.fetchOptionsChain(allocator, symbol); 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 { defer {
for (chains[1..]) |chain| { for (chains[1..]) |chain| {
allocator.free(chain.calls); allocator.free(chain.calls);
@ -182,7 +195,6 @@ pub const Finnhub = struct {
} }
allocator.free(chains); allocator.free(chains);
} }
if (chains.len == 0) return allocator.alloc(OptionContract, 0) catch return provider.ProviderError.OutOfMemory;
const first = chains[0]; const first = chains[0];
const total = first.calls.len + first.puts.len; const total = first.calls.len + first.puts.len;
const merged = allocator.alloc(OptionContract, total) catch const merged = allocator.alloc(OptionContract, total) catch
@ -389,43 +401,6 @@ fn parseEarningsResponse(
// -- Helpers -- // -- 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 { fn parseQuarter(val: ?std.json.Value) ?u8 {
const v = val orelse return null; const v = val orelse return null;
const i = switch (v) { const i = switch (v) {
@ -454,11 +429,115 @@ fn parseReportTime(val: ?std.json.Value) ReportTime {
return .unknown; return .unknown;
} }
fn mapHttpError(err: http.HttpError) provider.ProviderError { // -- Tests --
return switch (err) {
error.RateLimited => provider.ProviderError.RateLimited, test "parseEarningsResponse basic" {
error.Unauthorized => provider.ProviderError.Unauthorized, const body =
error.NotFound => provider.ProviderError.NotFound, \\{
else => provider.ProviderError.RequestFailed, \\ "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);
} }

View file

@ -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,
};
}

View file

@ -240,3 +240,147 @@ fn parseResponse(allocator: std.mem.Allocator, body: []const u8, expected_count:
return results; 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);
}

View file

@ -14,6 +14,10 @@ const Dividend = @import("../models/dividend.zig").Dividend;
const DividendType = @import("../models/dividend.zig").DividendType; const DividendType = @import("../models/dividend.zig").DividendType;
const Split = @import("../models/split.zig").Split; const Split = @import("../models/split.zig").Split;
const provider = @import("provider.zig"); 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"; 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 }); 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 { fn parseDateField(obj: std.json.ObjectMap, key: []const u8) ?Date {
const v = obj.get(key) orelse return null; const v = obj.get(key) orelse return null;
const s = switch (v) { const s = switch (v) {
@ -429,11 +415,136 @@ fn parseDividendType(obj: std.json.ObjectMap) DividendType {
return .unknown; return .unknown;
} }
fn mapHttpError(err: http.HttpError) provider.ProviderError { // -- Tests --
return switch (err) {
error.RateLimited => provider.ProviderError.RateLimited, test "parseDividendsPage basic" {
error.Unauthorized => provider.ProviderError.Unauthorized, const body =
error.NotFound => provider.ProviderError.NotFound, \\{
else => provider.ProviderError.RequestFailed, \\ "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);
} }

View file

@ -13,6 +13,8 @@ const RateLimiter = @import("../net/RateLimiter.zig");
const Date = @import("../models/date.zig").Date; const Date = @import("../models/date.zig").Date;
const Candle = @import("../models/candle.zig").Candle; const Candle = @import("../models/candle.zig").Candle;
const provider = @import("provider.zig"); const provider = @import("provider.zig");
const json_utils = @import("json_utils.zig");
const parseJsonFloat = json_utils.parseJsonFloat;
const base_url = "https://api.twelvedata.com"; 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 }; return .{ .parsed = parsed };
} }
/// Parse a JSON value that may be a string containing a number, or a number directly. /// TwelveData-specific: returns empty string instead of null for quote display convenience.
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,
};
}
fn jsonStr(val: ?std.json.Value) []const u8 { fn jsonStr(val: ?std.json.Value) []const u8 {
const v = val orelse return ""; const v = val orelse return "";
return switch (v) { return switch (v) {
@ -309,3 +301,183 @@ fn jsonStr(val: ?std.json.Value) []const u8 {
else => "", 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);
}