zfin/src/providers/cboe.zig

350 lines
12 KiB
Zig

//! CBOE delayed quotes provider -- options chains from the exchange itself.
//! No API key required. Data is 15-minute delayed during market hours.
//!
//! Endpoint: GET https://cdn.cboe.com/api/global/delayed_quotes/options/{SYMBOL}.json
//! Returns all expirations with full chains including greeks, bid/ask, volume, OI.
const std = @import("std");
const http = @import("../net/http.zig");
const RateLimiter = @import("../net/RateLimiter.zig");
const Date = @import("../Date.zig");
const OptionContract = @import("../models/option.zig").OptionContract;
const OptionsChain = @import("../models/option.zig").OptionsChain;
const ContractType = @import("../models/option.zig").ContractType;
const json_utils = @import("json_utils.zig");
const optFloat = json_utils.optFloat;
const optUint = json_utils.optUint;
const base_url = "https://cdn.cboe.com/api/global/delayed_quotes/options";
pub const Cboe = struct {
client: http.Client,
rate_limiter: RateLimiter,
allocator: std.mem.Allocator,
pub fn init(io: std.Io, allocator: std.mem.Allocator) Cboe {
return .{
.client = http.Client.init(io, allocator),
.rate_limiter = RateLimiter.perMinute(io, 30),
.allocator = allocator,
};
}
pub fn deinit(self: *Cboe) 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: *Cboe,
allocator: std.mem.Allocator,
symbol: []const u8,
) ![]OptionsChain {
self.rate_limiter.acquire();
// Build URL: {base_url}/{SYMBOL}.json
const url = try buildCboeUrl(allocator, symbol);
defer allocator.free(url);
// Request with Accept-Encoding: identity to avoid Zig 0.15 stdlib deflate panic
// on malformed compressed responses from CBOE's CDN.
var response = try self.client.request(.GET, url, null, &.{.{ .name = "Accept-Encoding", .value = "identity" }});
defer response.deinit();
return parseResponse(allocator, response.body, symbol);
}
};
fn buildCboeUrl(allocator: std.mem.Allocator, symbol: []const u8) ![]const u8 {
var aw: std.Io.Writer.Allocating = .init(allocator);
errdefer aw.deinit();
try aw.writer.writeAll(base_url);
try aw.writer.writeByte('/');
try aw.writer.writeAll(symbol);
try aw.writer.writeAll(".json");
return aw.toOwnedSlice();
}
/// Parse a CBOE options response into grouped OptionsChain slices.
fn parseResponse(
allocator: std.mem.Allocator,
body: []const u8,
symbol: []const u8,
) ![]OptionsChain {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
return error.ParseError;
defer parsed.deinit();
const root = switch (parsed.value) {
.object => |o| o,
else => return error.ParseError,
};
const data_obj = switch (root.get("data") orelse return error.ParseError) {
.object => |o| o,
else => return error.ParseError,
};
const underlying_price: ?f64 = if (data_obj.get("current_price")) |v| optFloat(v) else null;
const options_arr = switch (data_obj.get("options") orelse return error.ParseError) {
.array => |a| a.items,
else => return error.ParseError,
};
// Parse all contracts and group by expiration.
// Use an ArrayList of (expiration, calls_list, puts_list) tuples.
var exp_map = ExpMap{};
for (options_arr) |item| {
const obj = switch (item) {
.object => |o| o,
else => continue,
};
const occ_sym = switch (obj.get("option") orelse continue) {
.string => |s| s,
else => continue,
};
const occ = parseOccSymbol(occ_sym, symbol.len) orelse continue;
const contract = OptionContract{
.contract_type = occ.contract_type,
.strike = occ.strike,
.expiration = occ.expiration,
.bid = optFloat(obj.get("bid")),
.ask = optFloat(obj.get("ask")),
.last_price = optFloat(obj.get("last_trade_price")),
.volume = optUint(obj.get("volume")),
.open_interest = optUint(obj.get("open_interest")),
.implied_volatility = optFloat(obj.get("iv")),
.delta = optFloat(obj.get("delta")),
.gamma = optFloat(obj.get("gamma")),
.theta = optFloat(obj.get("theta")),
.vega = optFloat(obj.get("vega")),
};
// Find or create the expiration bucket
const bucket = try exp_map.getOrPut(allocator, occ.expiration);
switch (occ.contract_type) {
.call => try bucket.calls.append(allocator, contract),
.put => try bucket.puts.append(allocator, contract),
}
}
// Convert to sorted OptionsChain slice
// Each chain gets its own dupe of the symbol so callers can free them independently.
return exp_map.toOwnedChains(allocator, symbol, underlying_price);
}
// ── OCC symbol parsing ──────────────────────────────────────────────
const OccInfo = struct {
expiration: Date,
contract_type: ContractType,
strike: f64,
};
/// Parse OCC option symbol: AAPL260225C00205000
/// Format: {underlying}{YYMMDD}{C|P}{strike * 1000, zero-padded to 8 digits}
fn parseOccSymbol(sym: []const u8, underlying_len: usize) ?OccInfo {
// After underlying: 6 digits date + 1 char type + 8 digits strike = 15 chars
if (sym.len < underlying_len + 15) return null;
const rest = sym[underlying_len..];
// Parse date: YYMMDD
const yy = std.fmt.parseInt(i16, rest[0..2], 10) catch return null;
const mm = std.fmt.parseInt(u8, rest[2..4], 10) catch return null;
const dd = std.fmt.parseInt(u8, rest[4..6], 10) catch return null;
const expiration = Date.fromYmd(2000 + yy, mm, dd);
// Parse type
const contract_type: ContractType = switch (rest[6]) {
'C' => .call,
'P' => .put,
else => return null,
};
// Parse strike: 8 digits, divide by 1000
const strike_raw = std.fmt.parseInt(u64, rest[7..15], 10) catch return null;
const strike: f64 = @as(f64, @floatFromInt(strike_raw)) / 1000.0;
return .{
.expiration = expiration,
.contract_type = contract_type,
.strike = strike,
};
}
// ── Expiration grouping ─────────────────────────────────────────────
/// Maps expiration dates to call/put ArrayLists. Uses a simple sorted array
/// since the number of expirations is small (typically 10-30).
const ExpMap = struct {
entries: std.ArrayList(Entry) = .empty,
const Entry = struct {
expiration: Date,
calls: std.ArrayList(OptionContract) = .empty,
puts: std.ArrayList(OptionContract) = .empty,
};
const Bucket = struct {
calls: *std.ArrayList(OptionContract),
puts: *std.ArrayList(OptionContract),
};
fn getOrPut(self: *ExpMap, allocator: std.mem.Allocator, exp: Date) !Bucket {
for (self.entries.items) |*entry| {
if (entry.expiration.eql(exp)) {
return .{ .calls = &entry.calls, .puts = &entry.puts };
}
}
try self.entries.append(allocator, .{ .expiration = exp });
const last = &self.entries.items[self.entries.items.len - 1];
return .{ .calls = &last.calls, .puts = &last.puts };
}
/// Convert to owned []OptionsChain, sorted by expiration ascending.
/// Frees internal structures; caller owns the returned chains.
/// Each chain gets its own dupe of `symbol` so callers can free them independently.
fn toOwnedChains(
self: *ExpMap,
allocator: std.mem.Allocator,
symbol: []const u8,
underlying_price: ?f64,
) ![]OptionsChain {
// Sort entries by expiration
std.mem.sort(Entry, self.entries.items, {}, struct {
fn lessThan(_: void, a: Entry, b: Entry) bool {
return a.expiration.lessThan(b.expiration);
}
}.lessThan);
var chains = try allocator.alloc(OptionsChain, self.entries.items.len);
var initialized: usize = 0;
errdefer {
for (chains[0..initialized]) |c| {
allocator.free(c.underlying_symbol);
allocator.free(c.calls);
allocator.free(c.puts);
}
allocator.free(chains);
}
for (self.entries.items, 0..) |*entry, i| {
const owned_symbol = try allocator.dupe(u8, symbol);
errdefer allocator.free(owned_symbol);
const calls = try entry.calls.toOwnedSlice(allocator);
errdefer allocator.free(calls);
const puts = try entry.puts.toOwnedSlice(allocator);
chains[i] = .{
.underlying_symbol = owned_symbol,
.underlying_price = underlying_price,
.expiration = entry.expiration,
.calls = calls,
.puts = puts,
};
initialized += 1;
}
self.entries.deinit(allocator);
return chains;
}
};
// ── Tests ───────────────────────────────────────────────────────────
test "parseOccSymbol -- call" {
const info = parseOccSymbol("AAPL260225C00205000", 4).?;
try std.testing.expect(info.contract_type == .call);
try std.testing.expect(info.expiration.eql(Date.fromYmd(2026, 2, 25)));
try std.testing.expectApproxEqAbs(@as(f64, 205.0), info.strike, 0.001);
}
test "parseOccSymbol -- put" {
const info = parseOccSymbol("AMZN260306P00180000", 4).?;
try std.testing.expect(info.contract_type == .put);
try std.testing.expect(info.expiration.eql(Date.fromYmd(2026, 3, 6)));
try std.testing.expectApproxEqAbs(@as(f64, 180.0), info.strike, 0.001);
}
test "parseOccSymbol -- fractional strike" {
const info = parseOccSymbol("SPY260227C00555500", 3).?;
try std.testing.expectApproxEqAbs(@as(f64, 555.5), info.strike, 0.001);
}
test "parseOccSymbol -- invalid" {
try std.testing.expect(parseOccSymbol("X", 1) == null);
try std.testing.expect(parseOccSymbol("AAPL26022", 4) == null);
}
test "parseResponse basic" {
const body =
\\{
\\ "data": {
\\ "current_price": 185.50,
\\ "options": [
\\ {
\\ "option": "AAPL260320C00180000",
\\ "bid": 7.50,
\\ "ask": 7.80,
\\ "last_trade_price": 7.65,
\\ "volume": 1234,
\\ "open_interest": 5678,
\\ "iv": 0.25,
\\ "delta": 0.65
\\ },
\\ {
\\ "option": "AAPL260320P00180000",
\\ "bid": 2.10,
\\ "ask": 2.30
\\ }
\\ ]
\\ }
\\}
;
const allocator = std.testing.allocator;
const chains = try parseResponse(allocator, body, "AAPL");
defer {
for (chains) |chain| {
allocator.free(chain.underlying_symbol);
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.expectApproxEqAbs(@as(f64, 0.25), chains[0].calls[0].implied_volatility.?, 0.01);
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);
}
test "parseResponse missing data" {
const body =
\\{"data": {}}
;
const allocator = std.testing.allocator;
const result = parseResponse(allocator, body, "AAPL");
try std.testing.expectError(error.ParseError, result);
}