diff --git a/src/providers/finnhub.zig b/src/providers/finnhub.zig index 02fdf04..13f8494 100644 --- a/src/providers/finnhub.zig +++ b/src/providers/finnhub.zig @@ -1,11 +1,8 @@ -//! Finnhub API provider -- primary source for options chains and earnings. +//! Finnhub API provider -- primary source for earnings data. //! API docs: https://finnhub.io/docs/api //! //! Free tier: 60 requests/min, all US market data. //! -//! Options endpoint: GET /api/v1/stock/option-chain?symbol=X -//! Returns all expirations with full CALL/PUT chains including greeks. -//! //! Earnings endpoint: GET /api/v1/calendar/earnings?symbol=X&from=YYYY-MM-DD&to=YYYY-MM-DD //! Returns historical and upcoming earnings with EPS, revenue, estimates. @@ -13,16 +10,11 @@ const std = @import("std"); const http = @import("../net/http.zig"); const RateLimiter = @import("../net/RateLimiter.zig"); const Date = @import("../models/date.zig").Date; -const OptionContract = @import("../models/option.zig").OptionContract; -const OptionsChain = @import("../models/option.zig").OptionsChain; -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; @@ -47,63 +39,6 @@ pub const Finnhub = struct { self.client.deinit(); } - /// Fetch the full options chain for a symbol (all expirations). - /// Returns chains grouped by expiration date, sorted nearest-first. - pub fn fetchOptionsChain( - self: *Finnhub, - allocator: std.mem.Allocator, - symbol: []const u8, - ) provider.ProviderError![]OptionsChain { - self.rate_limiter.acquire(); - - const url = http.buildUrl(allocator, base_url ++ "/stock/option-chain", &.{ - .{ "symbol", symbol }, - .{ "token", 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 parseOptionsResponse(allocator, response.body, symbol); - } - - /// Fetch options for a specific expiration date only. - pub fn fetchOptionsForExpiration( - self: *Finnhub, - allocator: std.mem.Allocator, - symbol: []const u8, - expiration: Date, - ) provider.ProviderError!?OptionsChain { - const chains = try self.fetchOptionsChain(allocator, symbol); - defer { - for (chains) |chain| { - allocator.free(chain.calls); - allocator.free(chain.puts); - } - allocator.free(chains); - } - - for (chains) |chain| { - if (chain.expiration.eql(expiration)) { - // Copy the matching chain so caller owns the memory - const calls = allocator.dupe(OptionContract, chain.calls) catch - return provider.ProviderError.OutOfMemory; - errdefer allocator.free(calls); - const puts = allocator.dupe(OptionContract, chain.puts) catch - return provider.ProviderError.OutOfMemory; - return OptionsChain{ - .underlying_symbol = chain.underlying_symbol, - .underlying_price = chain.underlying_price, - .expiration = chain.expiration, - .calls = calls, - .puts = puts, - }; - } - } - return null; - } - /// Fetch earnings calendar for a symbol. /// Returns earnings events sorted newest-first (upcoming first, then historical). pub fn fetchEarnings( @@ -153,59 +88,10 @@ pub const Finnhub = struct { } const vtable = provider.Provider.VTable{ - .fetchOptions = @ptrCast(&fetchOptionsVtable), .fetchEarnings = @ptrCast(&fetchEarningsVtable), .name = .finnhub, }; - fn fetchOptionsVtable( - ptr: *Finnhub, - allocator: std.mem.Allocator, - symbol: []const u8, - expiration: ?Date, - ) provider.ProviderError![]OptionContract { - if (expiration) |exp| { - const chain = try ptr.fetchOptionsForExpiration(allocator, symbol, exp); - 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 { - 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); - allocator.free(c.puts); - return merged; - } - return allocator.alloc(OptionContract, 0) catch return provider.ProviderError.OutOfMemory; - } - // 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); - allocator.free(chain.puts); - } - allocator.free(chains); - } - const first = chains[0]; - const total = first.calls.len + first.puts.len; - const merged = allocator.alloc(OptionContract, total) catch - return provider.ProviderError.OutOfMemory; - @memcpy(merged[0..first.calls.len], first.calls); - @memcpy(merged[first.calls.len..], first.puts); - allocator.free(first.calls); - allocator.free(first.puts); - return merged; - } - fn fetchEarningsVtable( ptr: *Finnhub, allocator: std.mem.Allocator, @@ -217,122 +103,6 @@ pub const Finnhub = struct { // -- JSON parsing -- -fn parseOptionsResponse( - allocator: std.mem.Allocator, - body: []const u8, - symbol: []const u8, -) provider.ProviderError![]OptionsChain { - const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch - return provider.ProviderError.ParseError; - defer parsed.deinit(); - - const root = parsed.value.object; - - // Check for error response - if (root.get("error")) |_| return provider.ProviderError.RequestFailed; - - const underlying_price: f64 = if (root.get("lastTradePrice")) |v| parseJsonFloat(v) else 0; - - const data_arr = root.get("data") orelse { - const empty = allocator.alloc(OptionsChain, 0) catch return provider.ProviderError.OutOfMemory; - return empty; - }; - const items = switch (data_arr) { - .array => |a| a.items, - else => { - const empty = allocator.alloc(OptionsChain, 0) catch return provider.ProviderError.OutOfMemory; - return empty; - }, - }; - - var chains: std.ArrayList(OptionsChain) = .empty; - errdefer { - for (chains.items) |chain| { - allocator.free(chain.calls); - allocator.free(chain.puts); - } - chains.deinit(allocator); - } - - for (items) |item| { - const obj = switch (item) { - .object => |o| o, - else => continue, - }; - - const exp_str = jsonStr(obj.get("expirationDate")) orelse continue; - const expiration = Date.parse(exp_str) catch continue; - - const options_obj = (obj.get("options") orelse continue); - const options = switch (options_obj) { - .object => |o| o, - else => continue, - }; - - const calls = parseContracts(allocator, options.get("CALL"), .call, expiration) catch - return provider.ProviderError.OutOfMemory; - errdefer allocator.free(calls); - const puts = parseContracts(allocator, options.get("PUT"), .put, expiration) catch - return provider.ProviderError.OutOfMemory; - - chains.append(allocator, .{ - .underlying_symbol = symbol, - .underlying_price = underlying_price, - .expiration = expiration, - .calls = calls, - .puts = puts, - }) catch return provider.ProviderError.OutOfMemory; - } - - return chains.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; -} - -fn parseContracts( - allocator: std.mem.Allocator, - val: ?std.json.Value, - contract_type: ContractType, - expiration: Date, -) ![]OptionContract { - const arr = val orelse { - return try allocator.alloc(OptionContract, 0); - }; - const items = switch (arr) { - .array => |a| a.items, - else => return try allocator.alloc(OptionContract, 0), - }; - - var contracts: std.ArrayList(OptionContract) = .empty; - errdefer contracts.deinit(allocator); - - for (items) |item| { - const obj = switch (item) { - .object => |o| o, - else => continue, - }; - - const strike = parseJsonFloat(obj.get("strike") orelse continue); - if (strike <= 0) continue; - - contracts.append(allocator, .{ - .contract_type = contract_type, - .strike = strike, - .expiration = expiration, - .bid = optFloat(obj.get("bid")), - .ask = optFloat(obj.get("ask")), - .last_price = optFloat(obj.get("lastPrice")), - .volume = optUint(obj.get("volume")), - .open_interest = optUint(obj.get("openInterest")), - .implied_volatility = optFloat(obj.get("impliedVolatility")), - .delta = optFloat(obj.get("delta")), - .gamma = optFloat(obj.get("gamma")), - .theta = optFloat(obj.get("theta")), - .vega = optFloat(obj.get("vega")), - }) catch return error.OutOfMemory; - } - - return contracts.toOwnedSlice(allocator); -} - fn parseEarningsResponse( allocator: std.mem.Allocator, body: []const u8, @@ -498,46 +268,3 @@ test "parseEarningsResponse empty" { 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); -}