zfin/src/tui/options_tab.zig

536 lines
22 KiB
Zig

const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
const OptionsRow = tui.OptionsRow;
// ── Tab-local action enum ─────────────────────────────────────
//
// Options tab keybinds:
// - Enter : expand/collapse the row at the cursor.
// - `c` / `p` : collapse-or-expand all calls / puts.
// - Ctrl-1 .. Ctrl-9 : set NTM filter to N strikes around ATM.
pub const Action = enum {
/// Toggle the row at the cursor: expand/collapse an expiration,
/// or collapse/expand the calls/puts subsection at the cursor.
/// Mouse single-click on a row dispatches the same action.
expand_collapse,
collapse_all_calls,
collapse_all_puts,
filter_1,
filter_2,
filter_3,
filter_4,
filter_5,
filter_6,
filter_7,
filter_8,
filter_9,
};
// ── Tab-private state ─────────────────────────────────────────
pub const State = struct {
/// Loaded options chains for the active symbol. Owned by State;
/// freed via `deinit` and `reload`.
chains: ?[]zfin.OptionsChain = null,
/// Whether `activate` has populated `chains` (or set `disabled`).
/// The chains slice is null until the first successful fetch
/// even if `loaded == true` (failed fetches still mark loaded).
loaded: bool = false,
/// Timestamp of the chains fetch — drives the "data Xs ago"
/// header readout.
timestamp: i64 = 0,
/// Cursor position in the flattened options rows view.
cursor: usize = 0,
/// Per-expiration: is the expiration expanded (showing calls
/// + puts subsections)?
expanded: [64]bool = @splat(false),
/// Per-expiration: when expanded, are the calls collapsed?
calls_collapsed: [64]bool = @splat(false),
/// Per-expiration: when expanded, are the puts collapsed?
puts_collapsed: [64]bool = @splat(false),
/// Number of strikes around ATM to show. Adjusted with Ctrl-1..9.
near_the_money: usize = 8,
/// Flattened display rows (expirations + headers + contracts).
/// Rebuilt by `rebuildRows` whenever `expanded` or
/// `near_the_money` changes.
rows: std.ArrayList(OptionsRow) = .empty,
/// Number of styled lines emitted before the first data row.
/// Used by mouse-click handling to map screen rows to data rows.
header_lines: usize = 0,
};
// ── Tab framework contract ────────────────────────────────────
pub const tab = struct {
pub const ActionT = Action;
pub const StateT = State;
pub const default_bindings: []const framework.TabBinding(Action) = &.{
.{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } },
.{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } },
.{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } },
.{ .action = .filter_1, .key = .{ .codepoint = '1', .mods = .{ .ctrl = true } } },
.{ .action = .filter_2, .key = .{ .codepoint = '2', .mods = .{ .ctrl = true } } },
.{ .action = .filter_3, .key = .{ .codepoint = '3', .mods = .{ .ctrl = true } } },
.{ .action = .filter_4, .key = .{ .codepoint = '4', .mods = .{ .ctrl = true } } },
.{ .action = .filter_5, .key = .{ .codepoint = '5', .mods = .{ .ctrl = true } } },
.{ .action = .filter_6, .key = .{ .codepoint = '6', .mods = .{ .ctrl = true } } },
.{ .action = .filter_7, .key = .{ .codepoint = '7', .mods = .{ .ctrl = true } } },
.{ .action = .filter_8, .key = .{ .codepoint = '8', .mods = .{ .ctrl = true } } },
.{ .action = .filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } },
};
pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.expand_collapse = "Expand/collapse row",
.collapse_all_calls = "Toggle all calls",
.collapse_all_puts = "Toggle all puts",
.filter_1 = "Filter +/- 1 NTM",
.filter_2 = "Filter +/- 2 NTM",
.filter_3 = "Filter +/- 3 NTM",
.filter_4 = "Filter +/- 4 NTM",
.filter_5 = "Filter +/- 5 NTM",
.filter_6 = "Filter +/- 6 NTM",
.filter_7 = "Filter +/- 7 NTM",
.filter_8 = "Filter +/- 8 NTM",
.filter_9 = "Filter +/- 9 NTM",
});
pub const status_hints: []const Action = &.{
.collapse_all_calls,
.collapse_all_puts,
};
pub fn init(state: *State, app: *App) !void {
_ = app;
state.* = .{};
}
pub fn deinit(state: *State, app: *App) void {
if (state.chains) |chains| {
zfin.OptionsChain.freeSlice(app.allocator, chains);
}
state.rows.deinit(app.allocator);
state.* = .{};
}
pub fn activate(state: *State, app: *App) !void {
if (app.symbol.len == 0) return;
if (state.loaded) return;
loadData(state, app);
}
pub const deactivate = framework.noopDeactivate(State);
pub fn reload(state: *State, app: *App) !void {
// Drop chains first so loadData starts clean.
if (state.chains) |chains| {
zfin.OptionsChain.freeSlice(app.allocator, chains);
}
// Preserve user UX choices across refresh: cursor, expanded
// sections, near-the-money filter. Just clear the data.
state.chains = null;
state.loaded = false;
state.timestamp = 0;
loadData(state, app);
}
pub const tick = framework.noopTick(State);
pub fn handleAction(state: *State, app: *App, action: Action) void {
switch (action) {
.collapse_all_calls => toggleAllCallsPuts(state, app, true),
.collapse_all_puts => toggleAllCallsPuts(state, app, false),
.expand_collapse => toggleExpandAtCursor(state, app),
.filter_1, .filter_2, .filter_3, .filter_4, .filter_5, .filter_6, .filter_7, .filter_8, .filter_9 => {
const n = @intFromEnum(action) - @intFromEnum(Action.filter_1) + 1;
state.near_the_money = n;
rebuildRows(state, app);
var status_buf: [32]u8 = undefined;
const msg = std.fmt.bufPrint(&status_buf, "+/- {d} NTM strikes", .{n}) catch "Filter changed";
app.setStatus(msg);
},
}
}
/// Mouse handling: a single-click on a data row moves the
/// cursor to that row and toggles expand/collapse — same effect
/// as pressing Enter on the row. Returns `true` if the click
/// landed on a data row (consumed); `false` otherwise (unhandled,
/// e.g. clicks above the table or on blank lines).
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
if (mouse.button != .left) return false;
if (mouse.type != .press) return false;
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
if (content_row < state.header_lines) return false;
if (state.rows.items.len == 0) return false;
// Walk rows tracking styled line position to find which row
// was clicked. Each row = 1 styled line, except `puts_header`
// which emits an extra blank line before it.
const target_line = content_row - state.header_lines;
var current_line: usize = 0;
for (state.rows.items, 0..) |orow, oi| {
if (orow.kind == .puts_header) current_line += 1; // extra blank
if (current_line == target_line) {
state.cursor = oi;
toggleExpandAtCursor(state, app);
return true;
}
current_line += 1;
}
return false;
}
pub fn isDisabled(app: *App) bool {
_ = app;
return false;
}
/// Symbol-change reset. Drops chains + display rows, clears
/// cursor + per-expiration collapse flags. Preserves
/// `near_the_money` because that's a persistent UX choice
/// (not symbol-bound).
pub fn onSymbolChange(state: *State, app: *App) void {
if (state.chains) |chains| {
zfin.OptionsChain.freeSlice(app.allocator, chains);
}
state.chains = null;
state.loaded = false;
state.timestamp = 0;
state.cursor = 0;
state.expanded = @splat(false);
state.calls_collapsed = @splat(false);
state.puts_collapsed = @splat(false);
state.rows.clearRetainingCapacity();
state.header_lines = 0;
// near_the_money preserved.
}
/// Sync the row cursor to the new scroll extreme. The framework
/// updates `app.scroll_offset` itself; this hook just keeps the
/// tab's own cursor consistent with what's now visible.
pub fn onScroll(state: *State, app: *App, where: framework.ScrollEdge) void {
_ = app;
switch (where) {
.top => state.cursor = 0,
.bottom => {
if (state.rows.items.len > 0) {
state.cursor = state.rows.items.len - 1;
}
},
}
}
/// Step the row cursor by one row in `delta`'s direction. The
/// magnitude of `delta` is ignored — keys and wheel events
/// both move by a single row (matching legacy behavior). Returns
/// `false` when there are no rows to navigate so the framework
/// falls through to scrolling the viewport instead.
pub fn onCursorMove(state: *State, app: *App, delta: isize) bool {
if (state.rows.items.len == 0) return false;
stepCursor(&state.cursor, state.rows.items.len, delta);
ensureCursorVisible(state, &app.scroll_offset, app.visible_height);
return true;
}
};
// ── Cursor movement / visibility (private; called from onCursorMove) ──
fn stepCursor(cursor: *usize, row_count: usize, direction: isize) void {
if (direction > 0) {
if (row_count > 0 and cursor.* < row_count - 1) cursor.* += 1;
} else {
if (cursor.* > 0) cursor.* -= 1;
}
}
fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void {
const cursor_row = state.cursor + state.header_lines;
if (cursor_row < scroll_offset.*) {
scroll_offset.* = cursor_row;
}
if (cursor_row >= scroll_offset.* + visible_height) {
scroll_offset.* = cursor_row - visible_height + 1;
}
}
// ── Data loading ──────────────────────────────────────────────
fn loadData(state: *State, app: *App) void {
state.loaded = true;
if (state.chains) |chains| {
zfin.OptionsChain.freeSlice(app.allocator, chains);
}
state.chains = null;
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;
};
state.chains = result.data;
state.timestamp = result.timestamp;
state.cursor = 0;
state.expanded = @splat(false);
state.calls_collapsed = @splat(false);
state.puts_collapsed = @splat(false);
rebuildRows(state, app);
app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh");
}
// ── Expand/collapse the row at the cursor ─────────────────────
//
// Called both from the keybind handler (`expand_collapse` action,
// bound to Enter) and from the mouse handler (single-click on a
// row). The behavior depends on the row kind:
// - `expiration` row: toggle the expiration's expanded flag
// (showing/hiding the calls + puts subsections).
// - `calls_header`/`puts_header`: toggle that subsection's
// collapsed flag.
// - call/put contract rows: no-op (clicking a contract is
// reserved for future per-contract actions).
//
// After any change, rebuilds the flat row list to reflect the new
// layout. No-op if the cursor is out of range or rows are empty.
fn toggleExpandAtCursor(state: *State, app: *App) void {
if (state.rows.items.len == 0) return;
if (state.cursor >= state.rows.items.len) return;
const row = state.rows.items[state.cursor];
switch (row.kind) {
.expiration => {
if (row.exp_idx < state.expanded.len) {
state.expanded[row.exp_idx] = !state.expanded[row.exp_idx];
rebuildRows(state, app);
}
},
.calls_header => {
if (row.exp_idx < state.calls_collapsed.len) {
state.calls_collapsed[row.exp_idx] = !state.calls_collapsed[row.exp_idx];
rebuildRows(state, app);
}
},
.puts_header => {
if (row.exp_idx < state.puts_collapsed.len) {
state.puts_collapsed[row.exp_idx] = !state.puts_collapsed[row.exp_idx];
rebuildRows(state, app);
}
},
// Clicking on a contract does nothing (yet).
else => {},
}
}
// ── Row rebuilding (after expansion/collapse changes) ────────
pub fn rebuildRows(state: *State, app: *App) void {
state.rows.clearRetainingCapacity();
const chains = state.chains orelse return;
const atm_price = if (chains.len > 0) chains[0].underlying_price orelse 0 else @as(f64, 0);
for (chains, 0..) |chain, ci| {
state.rows.append(app.allocator, .{
.kind = .expiration,
.exp_idx = ci,
}) catch continue;
if (ci < state.expanded.len and state.expanded[ci]) {
// Calls header (always shown when expanded, acts as toggle)
state.rows.append(app.allocator, .{
.kind = .calls_header,
.exp_idx = ci,
}) catch continue;
// Calls contracts (only if not collapsed)
if (!(ci < state.calls_collapsed.len and state.calls_collapsed[ci])) {
const filtered_calls = fmt.filterNearMoney(chain.calls, atm_price, state.near_the_money);
for (filtered_calls) |cc| {
state.rows.append(app.allocator, .{
.kind = .call,
.exp_idx = ci,
.contract = cc,
}) catch continue;
}
}
// Puts header
state.rows.append(app.allocator, .{
.kind = .puts_header,
.exp_idx = ci,
}) catch continue;
// Puts contracts (only if not collapsed)
if (!(ci < state.puts_collapsed.len and state.puts_collapsed[ci])) {
const filtered_puts = fmt.filterNearMoney(chain.puts, atm_price, state.near_the_money);
for (filtered_puts) |p| {
state.rows.append(app.allocator, .{
.kind = .put,
.exp_idx = ci,
.contract = p,
}) catch continue;
}
}
}
}
}
// ── All-calls / all-puts toggle ──────────────────────────────
fn toggleAllCallsPuts(state: *State, app: *App, is_calls: bool) void {
const chains = state.chains orelse return;
// Determine whether to collapse or expand: if any expanded chain
// has this section visible, collapse all; otherwise expand all.
var any_visible = false;
for (chains, 0..) |_, ci| {
if (ci >= state.expanded.len) break;
if (!state.expanded[ci]) continue; // only count expanded expirations
if (is_calls) {
if (ci < state.calls_collapsed.len and !state.calls_collapsed[ci]) {
any_visible = true;
break;
}
} else {
if (ci < state.puts_collapsed.len and !state.puts_collapsed[ci]) {
any_visible = true;
break;
}
}
}
const new_state = any_visible;
for (chains, 0..) |_, ci| {
if (ci >= 64) break;
if (is_calls) {
state.calls_collapsed[ci] = new_state;
} else {
state.puts_collapsed[ci] = new_state;
}
}
rebuildRows(state, app);
if (is_calls) {
app.setStatus(if (new_state) "All calls collapsed" else "All calls expanded");
} else {
app.setStatus(if (new_state) "All puts collapsed" else "All puts expanded");
}
}
// ── Rendering ─────────────────────────────────────────────────
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const state = &app.states.options;
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 = state.chains 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, state.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| {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {f} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ Money.from(price), chains.len, state.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)
state.header_lines = lines.items.len;
// Flat list of options rows with inline expand/collapse
for (state.rows.items, 0..) |row, ri| {
const is_cursor = ri == state.cursor;
switch (row.kind) {
.expiration => {
if (row.exp_idx < chains.len) {
const chain = chains[row.exp_idx];
const is_expanded = row.exp_idx < state.expanded.len and state.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}{f} ({d} calls, {d} puts)", .{
arrow,
chain.expiration,
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 < state.calls_collapsed.len and state.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 < state.puts_collapsed.len and state.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);
}