IO-as-an-interface refactor across the codebase. The big shifts: - std.io → std.Io, std.fs → std.Io.Dir/File, std.process.Child → spawn/run. - Juicy Main: pub fn main(init: std.process.Init) gives gpa, io, arena, environ_map up front. main.zig + the build/ scripts use it directly. - Threading io through everywhere that touches the outside world (HTTP, files, stderr, sleep, terminal detection). Functions taking `io` now announce side effects at the call site — the smell is the feature. - date math takes `as_of: Date`, not `today: Date`. Caller resolves `--as-of` flag vs wall-clock at the boundary; the function operates on whatever date it's given. Every "today" parameter renamed and the as_of: ?Date + today: Date pattern collapsed. - now_s: i64 (or before_s/after_s pairs) for sub-second metadata fields like snapshot captured_at, audit cadence, formatAge/fmtTimeAgo. Also pure and testable. - legitimate Timestamp.now callers (cache TTL math, FetchResult timestamps, rate limiter, per-frame TUI "now" captures) gain `// wall-clock required: ...` comments justifying the read. Test discovery: replaced the local refAllDeclsRecursive with bare std.testing.refAllDecls(@This()). Sema-pulling main.zig's top-level decls reaches every test file transitively through the import graph; no explicit _ = @import(...) lines needed. Cleanup along the way: - Dropped DataService.allocator()/io() accessor methods; renamed the fields to drop the base_ prefix. Callers use self.allocator and self.io directly. - Dropped now-vestigial io parameters from buildSnapshot, analyzePortfolio, compareSchwabSummary, compareAccounts, buildPortfolioData, divs.display, quote.display, parsePortfolioOpts, aggregateLiveStocks, renderEarningsLines, capitalGainsIndicator, aggregateDripLots, printLotRow, portfolio.display, printSnapNote. - Dropped the unused contributions.computeAttribution date-form wrapper (only computeAttributionSpec is called). - formatAge/fmtTimeAgo take (before_s, after_s) instead of io and reading the clock internally. - parseProjectionsConfig uses an internal stack-buffer FixedBufferAllocator instead of an allocator parameter. - ThreadSafeAllocator wrappers in cache concurrency tests dropped (0.16's DebugAllocator is thread-safe by default). - analyzePortfolio bug surfaced by the rename: snapshot.zig was passing wall-clock today instead of as_of, mis-valuing cash/CDs for historical backfills. 83 new unit tests added due to removal of IO, bringing coverage from 58% -> 64%
143 lines
7.1 KiB
Zig
143 lines
7.1 KiB
Zig
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);
|
|
}
|