406 lines
14 KiB
Zig
406 lines
14 KiB
Zig
//! 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,
|
|
};
|
|
}
|