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 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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
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 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue