//! 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); }