//! Finnhub API provider -- primary source for options chains and earnings. //! 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. 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 base_url = "https://finnhub.io/api/v1"; pub const Finnhub = 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) Finnhub { return .{ .api_key = api_key, .client = http.Client.init(allocator), .rate_limiter = RateLimiter.perMinute(60), .allocator = allocator, }; } pub fn deinit(self: *Finnhub) void { 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( self: *Finnhub, allocator: std.mem.Allocator, symbol: []const u8, from: ?Date, to: ?Date, ) provider.ProviderError![]EarningsEvent { self.rate_limiter.acquire(); var params: [4][2][]const u8 = undefined; var n: usize = 0; params[n] = .{ "symbol", symbol }; n += 1; params[n] = .{ "token", self.api_key }; n += 1; var from_buf: [10]u8 = undefined; var to_buf: [10]u8 = undefined; if (from) |f| { params[n] = .{ "from", f.format(&from_buf) }; n += 1; } if (to) |t| { params[n] = .{ "to", t.format(&to_buf) }; n += 1; } const url = http.buildUrl(allocator, base_url ++ "/calendar/earnings", params[0..n]) catch return provider.ProviderError.OutOfMemory; defer allocator.free(url); var response = self.client.get(url) catch |err| return mapHttpError(err); defer response.deinit(); return parseEarningsResponse(allocator, response.body, symbol); } pub fn asProvider(self: *Finnhub) provider.Provider { return .{ .ptr = @ptrCast(self), .vtable = &vtable, }; } 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 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); defer { for (chains[1..]) |chain| { allocator.free(chain.calls); allocator.free(chain.puts); } 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 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, symbol: []const u8, ) provider.ProviderError![]EarningsEvent { return ptr.fetchEarnings(allocator, symbol, null, null); } }; // -- 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, symbol: []const u8, ) provider.ProviderError![]EarningsEvent { 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")) |_| return provider.ProviderError.RequestFailed; const cal = root.get("earningsCalendar") orelse { const empty = allocator.alloc(EarningsEvent, 0) catch return provider.ProviderError.OutOfMemory; return empty; }; const items = switch (cal) { .array => |a| a.items, else => { const empty = allocator.alloc(EarningsEvent, 0) catch return provider.ProviderError.OutOfMemory; return empty; }, }; var events: std.ArrayList(EarningsEvent) = .empty; errdefer events.deinit(allocator); for (items) |item| { const obj = switch (item) { .object => |o| o, else => continue, }; const date_str = jsonStr(obj.get("date")) orelse continue; const date = Date.parse(date_str) catch continue; const actual = optFloat(obj.get("epsActual")); const estimate = optFloat(obj.get("epsEstimate")); const surprise: ?f64 = if (actual != null and estimate != null) actual.? - estimate.? else null; const surprise_pct: ?f64 = if (surprise != null and estimate != null and estimate.? != 0) (surprise.? / @abs(estimate.?)) * 100.0 else null; events.append(allocator, .{ .symbol = symbol, .date = date, .estimate = estimate, .actual = actual, .surprise = surprise, .surprise_percent = surprise_pct, .quarter = parseQuarter(obj.get("quarter")), .fiscal_year = parseFiscalYear(obj.get("year")), .revenue_actual = optFloat(obj.get("revenueActual")), .revenue_estimate = optFloat(obj.get("revenueEstimate")), .report_time = parseReportTime(obj.get("hour")), }) catch return provider.ProviderError.OutOfMemory; } return events.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; } // -- 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) { .integer => |n| n, .float => |f| @as(i64, @intFromFloat(f)), else => return null, }; return if (i >= 1 and i <= 4) @intCast(i) else null; } fn parseFiscalYear(val: ?std.json.Value) ?i16 { const v = val orelse return null; const i = switch (v) { .integer => |n| n, .float => |f| @as(i64, @intFromFloat(f)), else => return null, }; return if (i > 1900 and i < 2200) @intCast(i) else null; } fn parseReportTime(val: ?std.json.Value) ReportTime { const s = jsonStr(val) orelse return .unknown; if (std.mem.eql(u8, s, "bmo")) return .bmo; if (std.mem.eql(u8, s, "amc")) return .amc; if (std.mem.eql(u8, s, "dmh")) return .dmh; 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, }; }