const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); const fmt = cli.fmt; const Money = @import("../Money.zig"); pub const ParsedArgs = struct { symbol: []const u8, /// `--ntm `: show ±N strikes near the money on the auto-expanded /// expiration. Default 8. ntm: usize = 8, }; pub const meta: framework.Meta = .{ .name = "options", .group = .symbol_lookup, .synopsis = "Show options chain (all expirations) for a symbol", .uppercase_first_arg = true, .help = \\Usage: zfin options [--ntm ] \\ \\Show the options chain (all expirations) for a symbol from \\CBOE. Cached for 1 hour. The nearest monthly expiration is \\auto-expanded with ±N strikes near the money; other \\expirations are listed collapsed. \\ \\Options: \\ --ntm Show ±N strikes near the money (default: 8) \\ \\Examples: \\ zfin options SPY \\ zfin options AAPL --ntm 12 \\ , .user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue, InvalidFlagValue }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len < 1) { cli.stderrPrint(ctx.io, "Error: 'options' requires a symbol argument\n"); return error.MissingSymbol; } var parsed: ParsedArgs = .{ .symbol = cmd_args[0] }; var i: usize = 1; while (i < cmd_args.len) : (i += 1) { const a = cmd_args[i]; if (std.mem.eql(u8, a, "--ntm")) { if (i + 1 >= cmd_args.len) { cli.stderrPrint(ctx.io, "Error: --ntm requires a value\n"); return error.MissingFlagValue; } parsed.ntm = std.fmt.parseInt(usize, cmd_args[i + 1], 10) catch { cli.stderrPrint(ctx.io, "Error: --ntm value must be a non-negative integer\n"); return error.InvalidFlagValue; }; i += 1; } else { cli.stderrPrint(ctx.io, "Error: unexpected argument to 'options': "); cli.stderrPrint(ctx.io, a); cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } } return parsed; } pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); const result = svc.getOptions(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.FetchFailed => { cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n"); return; }, else => { cli.stderrPrint(ctx.io, "Error loading options data.\n"); return; }, }; const ch = result.data; defer result.deinit(); if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached options data)\n"); if (ch.len == 0) { cli.stderrPrint(ctx.io, "No options data found.\n"); return; } try display(ctx.out, ch, parsed.symbol, parsed.ntm, ctx.color); } pub fn display(out: *std.Io.Writer, chains: []const zfin.OptionsChain, symbol: []const u8, ntm: usize, color: bool) !void { if (chains.len == 0) return; try cli.printBold(out, color, "\nOptions Chain for {s}\n", .{symbol}); try out.print("========================================\n", .{}); if (chains[0].underlying_price) |price| { try out.print("Underlying: {f} {d} expiration(s) +/- {d} strikes NTM\n", .{ Money.from(price), chains.len, ntm }); } else { try out.print("{d} expiration(s) available\n", .{chains.len}); } // Find nearest monthly expiration to auto-expand var auto_expand_idx: ?usize = null; for (chains, 0..) |chain, ci| { if (fmt.isMonthlyExpiration(chain.expiration)) { auto_expand_idx = ci; break; } } // If no monthly found, expand the first one if (auto_expand_idx == null and chains.len > 0) auto_expand_idx = 0; const atm_price = if (chains[0].underlying_price) |p| p else @as(f64, 0); // List all expirations, expanding the nearest monthly for (chains, 0..) |chain, ci| { const is_monthly = fmt.isMonthlyExpiration(chain.expiration); const is_expanded = auto_expand_idx != null and ci == auto_expand_idx.?; try out.print("\n", .{}); if (is_expanded) { try cli.setBold(out, color); try out.print("{f} ({d} calls, {d} puts)", .{ chain.expiration, chain.calls.len, chain.puts.len, }); if (is_monthly) try out.print(" [monthly]", .{}); try cli.reset(out, color); try out.print("\n", .{}); // Print calls try printSection(out, "CALLS", chain.calls, atm_price, ntm, true, color); try out.print("\n", .{}); // Print puts try printSection(out, "PUTS", chain.puts, atm_price, ntm, false, color); } else { try cli.setFg(out, color, if (is_monthly) cli.CLR_HEADER else cli.CLR_MUTED); try out.print("{f} ({d} calls, {d} puts)", .{ chain.expiration, chain.calls.len, chain.puts.len, }); if (is_monthly) try out.print(" [monthly]", .{}); try cli.reset(out, color); try out.print("\n", .{}); } } try out.print("\n", .{}); } pub fn printSection( out: *std.Io.Writer, label: []const u8, contracts: []const zfin.OptionContract, atm_price: f64, ntm: usize, is_calls: bool, color: bool, ) !void { try cli.printBold(out, color, " {s}\n", .{label}); try cli.setFg(out, color, cli.CLR_MUTED); try out.print(" {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}\n", .{ "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV", }); try out.print(" {s:->10} {s:->10} {s:->10} {s:->10} {s:->10} {s:->8} {s:->8}\n", .{ "", "", "", "", "", "", "", }); try cli.reset(out, color); const filtered = fmt.filterNearMoney(contracts, atm_price, ntm); for (filtered) |c| { const itm = if (is_calls) c.strike <= atm_price else c.strike >= atm_price; const prefix: []const u8 = if (itm) " |" else " "; var contract_buf: [128]u8 = undefined; const line = fmt.fmtContractLine(&contract_buf, prefix, c); try out.print("{s}\n", .{line}); } } // ── Tests ──────────────────────────────────────────────────── test "parseArgs: accepts a symbol with default ntm" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"SPY"}; const parsed = try parseArgs(&ctx, &args); try std.testing.expectEqualStrings("SPY", parsed.symbol); try std.testing.expectEqual(@as(usize, 8), parsed.ntm); } test "parseArgs: --ntm overrides default" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{ "SPY", "--ntm", "12" }; const parsed = try parseArgs(&ctx, &args); try std.testing.expectEqual(@as(usize, 12), parsed.ntm); } test "parseArgs: missing symbol errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{}; try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args)); } test "parseArgs: --ntm without value errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{ "SPY", "--ntm" }; try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args)); } test "parseArgs: --ntm with non-numeric value errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{ "SPY", "--ntm", "abc" }; try std.testing.expectError(error.InvalidFlagValue, parseArgs(&ctx, &args)); } test "parseArgs: unknown flag errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{ "SPY", "--bogus" }; try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); } test "printSection shows header and contracts" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const calls = [_]zfin.OptionContract{ .{ .contract_type = .call, .strike = 150.0, .expiration = .{ .days = 20100 }, .bid = 5.0, .ask = 5.50, .last_price = 5.25 }, .{ .contract_type = .call, .strike = 155.0, .expiration = .{ .days = 20100 }, .bid = 2.0, .ask = 2.50, .last_price = 2.25 }, }; try printSection(&w, "CALLS", &calls, 152.0, 8, true, false); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "CALLS") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Strike") != null); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); } test "display shows chain header no color" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const calls = [_]zfin.OptionContract{}; const puts = [_]zfin.OptionContract{}; const chains = [_]zfin.OptionsChain{ .{ .underlying_symbol = "SPY", .underlying_price = 500.0, .expiration = .{ .days = 20100 }, .calls = &calls, .puts = &puts }, }; try display(&w, &chains, "SPY", 8, false); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Options Chain for SPY") != null); try std.testing.expect(std.mem.indexOf(u8, out, "1 expiration(s)") != null); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); }