350 lines
12 KiB
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);
|
|
}
|