//! Alpha Vantage API provider -- used for ETF profiles (free endpoint). //! API docs: https://www.alphavantage.co/documentation/ //! //! Free tier: 25 requests/day. Only used for data other providers don't have. //! //! ETF Profile endpoint: GET /query?function=ETF_PROFILE&symbol=X&apikey=KEY //! Returns net assets, expense ratio, sector weights, top holdings, etc. const std = @import("std"); const http = @import("../net/http.zig"); const RateLimiter = @import("../net/RateLimiter.zig"); const Date = @import("../models/date.zig").Date; 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"; /// Company overview data from Alpha Vantage OVERVIEW endpoint. pub const CompanyOverview = struct { symbol: []const u8, name: ?[]const u8 = null, sector: ?[]const u8 = null, industry: ?[]const u8 = null, country: ?[]const u8 = null, market_cap: ?[]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 { api_key: []const u8, client: http.Client, rate_limiter: RateLimiter, allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator, api_key: []const u8) AlphaVantage { return .{ .api_key = api_key, .client = http.Client.init(allocator), .rate_limiter = RateLimiter.perDay(25), .allocator = allocator, }; } pub fn deinit(self: *AlphaVantage) void { self.client.deinit(); } /// Fetch company overview (sector, industry, country) for a stock symbol. pub fn fetchCompanyOverview( self: *AlphaVantage, allocator: std.mem.Allocator, symbol: []const u8, ) provider.ProviderError!CompanyOverview { self.rate_limiter.acquire(); const url = http.buildUrl(allocator, base_url, &.{ .{ "function", "OVERVIEW" }, .{ "symbol", symbol }, .{ "apikey", self.api_key }, }) catch return provider.ProviderError.OutOfMemory; defer allocator.free(url); var response = self.client.get(url) catch |err| return mapHttpError(err); defer response.deinit(); return parseCompanyOverview(allocator, response.body, symbol); } /// Fetch ETF profile data: expense ratio, holdings, sectors, etc. pub fn fetchEtfProfile( self: *AlphaVantage, allocator: std.mem.Allocator, symbol: []const u8, ) provider.ProviderError!EtfProfile { self.rate_limiter.acquire(); const url = http.buildUrl(allocator, base_url, &.{ .{ "function", "ETF_PROFILE" }, .{ "symbol", symbol }, .{ "apikey", self.api_key }, }) catch return provider.ProviderError.OutOfMemory; defer allocator.free(url); var response = self.client.get(url) catch |err| return mapHttpError(err); defer response.deinit(); return parseEtfProfileResponse(allocator, response.body, symbol); } pub fn asProvider(self: *AlphaVantage) provider.Provider { return .{ .ptr = @ptrCast(self), .vtable = &vtable, }; } const vtable = provider.Provider.VTable{ .fetchEtfProfile = @ptrCast(&fetchEtfProfileVtable), .name = .alphavantage, }; fn fetchEtfProfileVtable( ptr: *AlphaVantage, allocator: std.mem.Allocator, symbol: []const u8, ) provider.ProviderError!EtfProfile { return ptr.fetchEtfProfile(allocator, symbol); } }; // -- JSON parsing -- fn parseEtfProfileResponse( allocator: std.mem.Allocator, body: []const u8, symbol: []const u8, ) provider.ProviderError!EtfProfile { const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return provider.ProviderError.ParseError; defer parsed.deinit(); const root = parsed.value.object; // Alpha Vantage returns {"Error Message": "..."} or {"Note": "..."} on error/rate limit if (root.get("Error Message")) |_| return provider.ProviderError.RequestFailed; if (root.get("Note")) |_| return provider.ProviderError.RateLimited; if (root.get("Information")) |_| return provider.ProviderError.RateLimited; var profile = EtfProfile{ .symbol = symbol, }; if (root.get("net_assets")) |v| { profile.net_assets = parseStrFloat(v); } if (root.get("net_expense_ratio")) |v| { profile.expense_ratio = parseStrFloat(v); } if (root.get("portfolio_turnover")) |v| { profile.portfolio_turnover = parseStrFloat(v); } if (root.get("dividend_yield")) |v| { profile.dividend_yield = parseStrFloat(v); } if (root.get("inception_date")) |v| { if (jsonStr(v)) |s| { profile.inception_date = Date.parse(s) catch null; } } if (root.get("leveraged")) |v| { if (jsonStr(v)) |s| { profile.leveraged = std.mem.eql(u8, s, "YES"); } } // Parse sectors if (root.get("sectors")) |sectors_val| { if (sectors_val == .array) { var sectors: std.ArrayList(SectorWeight) = .empty; errdefer sectors.deinit(allocator); for (sectors_val.array.items) |item| { const obj = switch (item) { .object => |o| o, else => continue, }; const name = jsonStr(obj.get("sector")) orelse continue; const weight = parseStrFloat(obj.get("weight") orelse continue) orelse continue; const duped_name = allocator.dupe(u8, name) catch return provider.ProviderError.OutOfMemory; sectors.append(allocator, .{ .name = duped_name, .weight = weight, }) catch return provider.ProviderError.OutOfMemory; } profile.sectors = sectors.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; } } // Parse top holdings (limit to top 20 to keep output manageable) if (root.get("holdings")) |holdings_val| { if (holdings_val == .array) { const max_holdings: usize = 20; var holdings: std.ArrayList(Holding) = .empty; errdefer holdings.deinit(allocator); const total: u32 = @intCast(holdings_val.array.items.len); profile.total_holdings = total; const limit = @min(holdings_val.array.items.len, max_holdings); for (holdings_val.array.items[0..limit]) |item| { const obj = switch (item) { .object => |o| o, else => continue, }; const desc = jsonStr(obj.get("description")) orelse continue; const weight = parseStrFloat(obj.get("weight") orelse continue) orelse continue; const duped_sym = if (jsonStr(obj.get("symbol"))) |s| (allocator.dupe(u8, s) catch return provider.ProviderError.OutOfMemory) else null; const duped_name = allocator.dupe(u8, desc) catch return provider.ProviderError.OutOfMemory; holdings.append(allocator, .{ .symbol = duped_sym, .name = duped_name, .weight = weight, }) catch return provider.ProviderError.OutOfMemory; } profile.holdings = holdings.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; } } return profile; } // -- Helpers -- fn parseStrFloat(val: ?std.json.Value) ?f64 { const v = val orelse return null; return switch (v) { .string => |s| std.fmt.parseFloat(f64, s) catch null, .float => |f| f, .integer => |i| @as(f64, @floatFromInt(i)), .null => null, else => null, }; } fn parseCompanyOverview( allocator: std.mem.Allocator, body: []const u8, symbol: []const u8, ) provider.ProviderError!CompanyOverview { const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return provider.ProviderError.ParseError; defer parsed.deinit(); const root = parsed.value.object; if (root.get("Error Message")) |_| return provider.ProviderError.RequestFailed; if (root.get("Note")) |_| return provider.ProviderError.RateLimited; if (root.get("Information")) |_| return provider.ProviderError.RateLimited; return .{ .symbol = symbol, .name = if (jsonStr(root.get("Name"))) |s| allocator.dupe(u8, s) catch null else null, .sector = if (jsonStr(root.get("Sector"))) |s| allocator.dupe(u8, s) catch null else null, .industry = if (jsonStr(root.get("Industry"))) |s| allocator.dupe(u8, s) catch null else null, .country = if (jsonStr(root.get("Country"))) |s| allocator.dupe(u8, s) catch null else null, .market_cap = if (jsonStr(root.get("MarketCapitalization"))) |s| allocator.dupe(u8, s) catch null else null, .asset_type = if (jsonStr(root.get("AssetType"))) |s| allocator.dupe(u8, s) catch null else null, }; }