const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const App = tui.App; const StyledLine = tui.StyledLine; // ── Data loading ────────────────────────────────────────────── pub fn loadData(app: *App) void { app.options_loaded = true; app.freeOptions(); const result = app.svc.getOptions(app.symbol) catch |err| { switch (err) { zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"), else => app.setStatus("Error loading options"), } return; }; app.options_data = result.data; app.options_timestamp = result.timestamp; app.options_cursor = 0; app.options_expanded = @splat(false); app.options_calls_collapsed = @splat(false); app.options_puts_collapsed = @splat(false); app.rebuildOptionsRows(); app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh"); } // ── Rendering ───────────────────────────────────────────────── pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); if (app.symbol.len == 0) { try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } const chains = app.options_data orelse { try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); }; if (chains.len == 0) { try lines.append(arena, .{ .text = " No options data found.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } var opt_ago_buf: [16]u8 = undefined; // wall-clock required: per-frame "now" for the "refreshed Xs ago" // readout. Captured here rather than on `app` so it refreshes every // time this tab renders. const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, app.options_timestamp, now_s); if (opt_ago.len > 0) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ app.symbol, opt_ago }), .style = th.headerStyle() }); } else { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{app.symbol}), .style = th.headerStyle() }); } if (chains[0].underlying_price) |price| { var price_buf: [24]u8 = undefined; try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, app.options_near_the_money }), .style = th.contentStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Track header line count for mouse click mapping (after all non-data lines) app.options_header_lines = lines.items.len; // Flat list of options rows with inline expand/collapse for (app.options_rows.items, 0..) |row, ri| { const is_cursor = ri == app.options_cursor; switch (row.kind) { .expiration => { if (row.exp_idx < chains.len) { const chain = chains[row.exp_idx]; var db: [10]u8 = undefined; const is_expanded = row.exp_idx < app.options_expanded.len and app.options_expanded[row.exp_idx]; const is_monthly = fmt.isMonthlyExpiration(chain.expiration); const arrow: []const u8 = if (is_expanded) "v " else "> "; const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{ arrow, chain.expiration.format(&db), chain.calls.len, chain.puts.len, }); const style = if (is_cursor) th.selectStyle() else if (is_monthly) th.contentStyle() else th.mutedStyle(); try lines.append(arena, .{ .text = text, .style = style }); } }, .calls_header => { const calls_collapsed = row.exp_idx < app.options_calls_collapsed.len and app.options_calls_collapsed[row.exp_idx]; const arrow: []const u8 = if (calls_collapsed) " > " else " v "; const style = if (is_cursor) th.selectStyle() else th.headerStyle(); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Calls", .{ arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV", }), .style = style }); }, .puts_header => { const puts_collapsed = row.exp_idx < app.options_puts_collapsed.len and app.options_puts_collapsed[row.exp_idx]; const arrow: []const u8 = if (puts_collapsed) " > " else " v "; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const style = if (is_cursor) th.selectStyle() else th.headerStyle(); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Puts", .{ arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV", }), .style = style }); }, .call => { if (row.contract) |cc| { const atm_price = chains[0].underlying_price orelse 0; const itm = cc.strike <= atm_price; const prefix: []const u8 = if (itm) " |" else " "; var contract_buf: [128]u8 = undefined; const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, cc)); const style = if (is_cursor) th.selectStyle() else th.contentStyle(); try lines.append(arena, .{ .text = text, .style = style }); } }, .put => { if (row.contract) |p| { const atm_price = chains[0].underlying_price orelse 0; const itm = p.strike >= atm_price; const prefix: []const u8 = if (itm) " |" else " "; var contract_buf: [128]u8 = undefined; const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, p)); const style = if (is_cursor) th.selectStyle() else th.contentStyle(); try lines.append(arena, .{ .text = text, .style = style }); } }, } } return lines.toOwnedSlice(arena); }