add tests to providers/consolidate common json utilities
This commit is contained in:
parent
f1e9321bdc
commit
bbdf340de4
7 changed files with 877 additions and 134 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
61
src/providers/json_utils.zig
Normal file
61
src/providers/json_utils.zig
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue