537 lines
22 KiB
Zig
537 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;
|
|
if (mouse.row == 0) return false; // tab bar — App handles
|
|
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);
|
|
}
|