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