ai: refactor tui/fix options panic

This commit is contained in:
Emil Lerch 2026-03-19 09:29:59 -07:00
parent 7a907fcc8d
commit 71f328b329
Signed by: lobo
GPG key ID: A7B62D657EF764F8
9 changed files with 2362 additions and 2213 deletions

View file

@ -48,7 +48,7 @@ pub const Client = struct {
return self.request(.POST, url, body, extra_headers); return self.request(.POST, url, body, extra_headers);
} }
fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response { pub fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
var attempt: u8 = 0; var attempt: u8 = 0;
while (true) : (attempt += 1) { while (true) : (attempt += 1) {
const response = self.doRequest(method, url, body, extra_headers) catch { const response = self.doRequest(method, url, body, extra_headers) catch {

View file

@ -47,7 +47,9 @@ pub const Cboe = struct {
const url = try buildCboeUrl(allocator, symbol); const url = try buildCboeUrl(allocator, symbol);
defer allocator.free(url); defer allocator.free(url);
var response = try self.client.get(url); // Request with Accept-Encoding: identity to avoid Zig 0.15 stdlib deflate panic
// on malformed compressed responses from CBOE's CDN.
var response = try self.client.request(.GET, url, null, &.{.{ .name = "Accept-Encoding", .value = "identity" }});
defer response.deinit(); defer response.deinit();
return parseResponse(allocator, response.body, symbol); return parseResponse(allocator, response.body, symbol);

File diff suppressed because it is too large Load diff

258
src/tui/analysis_tab.zig Normal file
View file

@ -0,0 +1,258 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme_mod = @import("theme.zig");
const tui = @import("../tui.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
// Data loading
pub fn loadData(self: *App) void {
self.analysis_loaded = true;
// Ensure portfolio is loaded first
if (!self.portfolio_loaded) self.loadPortfolioData();
const pf = self.portfolio orelse return;
const summary = self.portfolio_summary orelse return;
// Load classification metadata file
if (self.classification_map == null) {
// Look for metadata.srf next to the portfolio file
if (self.portfolio_path) |ppath| {
// Derive metadata path: same directory as portfolio, named "metadata.srf"
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(self.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
defer self.allocator.free(meta_path);
const file_data = std.fs.cwd().readFileAlloc(self.allocator, meta_path, 1024 * 1024) catch {
self.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
return;
};
defer self.allocator.free(file_data);
self.classification_map = zfin.classification.parseClassificationFile(self.allocator, file_data) catch {
self.setStatus("Error parsing metadata.srf");
return;
};
}
}
// Load account tax type metadata file (optional)
if (self.account_map == null) {
if (self.portfolio_path) |ppath| {
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch {
loadDataFinish(self, pf, summary);
return;
};
defer self.allocator.free(acct_path);
if (std.fs.cwd().readFileAlloc(self.allocator, acct_path, 1024 * 1024)) |acct_data| {
defer self.allocator.free(acct_data);
self.account_map = zfin.analysis.parseAccountsFile(self.allocator, acct_data) catch null;
} else |_| {
// accounts.srf is optional -- analysis works without it
}
}
}
loadDataFinish(self, pf, summary);
}
fn loadDataFinish(self: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
const cm = self.classification_map orelse {
self.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
return;
};
// Free previous result
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
self.analysis_result = zfin.analysis.analyzePortfolio(
self.allocator,
summary.allocations,
cm,
pf,
summary.total_value,
self.account_map,
) catch {
self.setStatus("Error computing analysis");
return;
};
}
// Rendering
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return renderAnalysisLines(arena, self.theme, self.analysis_result);
}
/// Render analysis tab content. Pure function no App dependency.
pub fn renderAnalysisLines(
arena: std.mem.Allocator,
th: theme_mod.Theme,
analysis_result: ?zfin.analysis.AnalysisResult,
) ![]const StyledLine {
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Portfolio Analysis", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const result = analysis_result orelse {
try lines.append(arena, .{ .text = " No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = " Run: zfin enrich <portfolio.srf> > metadata.srf", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
const bar_width: usize = 30;
const label_width: usize = 24;
const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{
.{ .items = result.asset_class, .title = " Asset Class" },
.{ .items = result.sector, .title = " Sector (Equities)" },
.{ .items = result.geo, .title = " Geographic" },
.{ .items = result.account, .title = " By Account" },
.{ .items = result.tax_type, .title = " By Tax Type" },
};
for (sections, 0..) |sec, si| {
if (si > 0 and sec.items.len == 0) continue;
if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = sec.title, .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
for (sec.items) |item| {
const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width });
}
}
if (result.unclassified.len > 0) {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Unclassified (not in metadata.srf)", .style = th.warningStyle() });
for (result.unclassified) |sym| {
const text = try std.fmt.allocPrint(arena, " {s}", .{sym});
try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
}
}
return lines.toOwnedSlice(arena);
}
pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 {
var val_buf: [24]u8 = undefined;
const pct = item.weight * 100.0;
const bar = try buildBlockBar(arena, item.weight, bar_width);
// Build label padded to label_width
const lbl = item.label;
const lbl_len = @min(lbl.len, label_width);
const padded_label = try arena.alloc(u8, label_width);
@memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]);
if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' ');
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {s}", .{
padded_label, bar, pct, fmt.fmtMoneyAbs(&val_buf, item.value),
});
}
/// Build a bar using Unicode block elements for sub-character precision.
/// Wraps fmt.buildBlockBar into arena-allocated memory.
pub fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 {
var buf: [256]u8 = undefined;
const result = fmt.buildBlockBar(&buf, weight, total_chars);
return arena.dupe(u8, result);
}
// Tests
const testing = std.testing;
test "buildBlockBar empty" {
const bar = try buildBlockBar(testing.allocator, 0, 10);
defer testing.allocator.free(bar);
// All spaces
try testing.expectEqual(@as(usize, 10), bar.len);
try testing.expectEqualStrings(" ", bar);
}
test "buildBlockBar full" {
const bar = try buildBlockBar(testing.allocator, 1.0, 5);
defer testing.allocator.free(bar);
// 5 full blocks, each 3 bytes UTF-8 ( = E2 96 88)
try testing.expectEqual(@as(usize, 15), bar.len);
// Verify first block is
try testing.expectEqualStrings("\xe2\x96\x88", bar[0..3]);
}
test "buildBlockBar partial" {
const bar = try buildBlockBar(testing.allocator, 0.5, 10);
defer testing.allocator.free(bar);
// 50% of 10 chars = 5 full blocks (no partial)
// 5 full blocks (15 bytes) + 5 spaces = 20 bytes
try testing.expectEqual(@as(usize, 20), bar.len);
}
test "fmtBreakdownLine formats correctly" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const item = zfin.analysis.BreakdownItem{
.label = "US Stock",
.weight = 0.65,
.value = 130000,
};
const line = try fmtBreakdownLine(arena, item, 10, 12);
// Should contain the label, percentage, and dollar amount
try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null);
try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null);
try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null);
}
test "renderAnalysisLines with data" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
var asset_class = [_]zfin.analysis.BreakdownItem{
.{ .label = "US Stock", .weight = 0.60, .value = 120000 },
.{ .label = "Int'l Stock", .weight = 0.40, .value = 80000 },
};
const result = zfin.analysis.AnalysisResult{
.asset_class = &asset_class,
.sector = &.{},
.geo = &.{},
.account = &.{},
.tax_type = &.{},
.unclassified = &.{},
.total_value = 200000,
};
const lines = try renderAnalysisLines(arena, th, result);
// Should have header section + asset class items
try testing.expect(lines.len >= 5);
// Find "Portfolio Analysis" header
var found_header = false;
for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "Portfolio Analysis") != null) found_header = true;
}
try testing.expect(found_header);
// Find asset class data
var found_us = false;
for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "US Stock") != null) found_us = true;
}
try testing.expect(found_us);
}
test "renderAnalysisLines no data" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
const lines = try renderAnalysisLines(arena, th, null);
try testing.expectEqual(@as(usize, 5), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
}

161
src/tui/earnings_tab.zig Normal file
View file

@ -0,0 +1,161 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme_mod = @import("theme.zig");
const tui = @import("../tui.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
// Data loading
pub fn loadData(self: *App) void {
self.earnings_loaded = true;
self.freeEarnings();
const result = self.svc.getEarnings(self.symbol) catch |err| {
switch (err) {
zfin.DataError.NoApiKey => self.setStatus("No API key. Set FINNHUB_API_KEY"),
zfin.DataError.FetchFailed => {
self.earnings_disabled = true;
self.setStatus("No earnings data (ETF/index?)");
},
else => self.setStatus("Error loading earnings"),
}
return;
};
self.earnings_data = result.data;
self.earnings_timestamp = result.timestamp;
if (result.data.len == 0) {
self.earnings_disabled = true;
self.setStatus("No earnings data available (ETF/index?)");
return;
}
self.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
}
// Rendering
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return renderEarningsLines(arena, self.theme, self.symbol, self.earnings_disabled, self.earnings_data, self.earnings_timestamp);
}
/// Render earnings tab content. Pure function no App dependency.
pub fn renderEarningsLines(
arena: std.mem.Allocator,
th: theme_mod.Theme,
symbol: []const u8,
earnings_disabled: bool,
earnings_data: ?[]const zfin.EarningsEvent,
earnings_timestamp: i64,
) ![]const StyledLine {
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
if (earnings_disabled) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
var earn_ago_buf: [16]u8 = undefined;
const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, earnings_timestamp);
if (earn_ago.len > 0) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ symbol, earn_ago }), .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s}", .{symbol}), .style = th.headerStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const ev = earnings_data orelse {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
if (ev.len == 0) {
try lines.append(arena, .{ .text = " No earnings events found.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{
"Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %",
}), .style = th.mutedStyle() });
for (ev) |e| {
var row_buf: [128]u8 = undefined;
const row = fmt.fmtEarningsRow(&row_buf, e);
const text = try std.fmt.allocPrint(arena, " {s}", .{row.text});
const row_style = if (row.is_future) th.mutedStyle() else if (row.is_positive) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = text, .style = row_style });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {d} earnings event(s)", .{ev.len}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
// Tests
const testing = std.testing;
test "renderEarningsLines with earnings data" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
const events = [_]zfin.EarningsEvent{.{
.symbol = "AAPL",
.date = try zfin.Date.parse("2025-01-15"),
.quarter = 4,
.estimate = 1.50,
.actual = 1.65,
}};
const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0);
// blank + header + blank + col_header + data_row + blank + count = 7
try testing.expectEqual(@as(usize, 7), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[1].text, "AAPL") != null);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "EPS Est") != null);
// Data row should contain the date
try testing.expect(std.mem.indexOf(u8, lines[4].text, "2025-01-15") != null);
}
test "renderEarningsLines no symbol" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
const lines = try renderEarningsLines(arena, th, "", false, null, 0);
try testing.expectEqual(@as(usize, 2), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null);
}
test "renderEarningsLines disabled" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
const lines = try renderEarningsLines(arena, th, "VTI", true, null, 0);
try testing.expectEqual(@as(usize, 2), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null);
}
test "renderEarningsLines no data" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0);
try testing.expectEqual(@as(usize, 4), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No data") != null);
}

116
src/tui/options_tab.zig Normal file
View file

@ -0,0 +1,116 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme_mod = @import("theme.zig");
const tui = @import("../tui.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
// Rendering
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme;
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (self.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 = self.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;
const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, self.options_timestamp);
if (opt_ago.len > 0) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ self.symbol, opt_ago }), .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{self.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, self.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)
self.options_header_lines = lines.items.len;
// Flat list of options rows with inline expand/collapse
for (self.options_rows.items, 0..) |row, ri| {
const is_cursor = ri == self.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 < self.options_expanded.len and self.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 < self.options_calls_collapsed.len and self.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 < self.options_puts_collapsed.len and self.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);
}

151
src/tui/perf_tab.zig Normal file
View file

@ -0,0 +1,151 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme_mod = @import("theme.zig");
const tui = @import("../tui.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
// Rendering
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme;
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (self.symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
if (self.candle_last_date) |d| {
var pdate_buf: [10]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {s})", .{ self.symbol, d.format(&pdate_buf) }), .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{self.symbol}), .style = th.headerStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (self.candles == null and !self.perf_loaded) self.loadPerfData();
if (self.trailing_price == null) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{self.symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
if (self.candle_count > 0) {
if (self.candle_first_date) |first| {
if (self.candle_last_date) |last| {
var fb: [10]u8 = undefined;
var lb: [10]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({s} to {s})", .{
self.candle_count, first.format(&fb), last.format(&lb),
}), .style = th.mutedStyle() });
}
}
}
if (self.candles) |cc| {
if (cc.len > 0) {
var close_buf: [24]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoneyAbs(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() });
}
}
const has_total = self.trailing_total != null;
if (self.candle_last_date) |last| {
var db: [10]u8 = undefined;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {s}:", .{last.format(&db)}), .style = th.headerStyle() });
}
try appendStyledReturnsTable(arena, &lines, self.trailing_price.?, if (has_total) self.trailing_total else null, th);
{
const today = fmt.todayDate();
const month_end = today.lastDayOfPriorMonth();
var db: [10]u8 = undefined;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({s}):", .{month_end.format(&db)}), .style = th.headerStyle() });
}
if (self.trailing_me_price) |me_price| {
try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) self.trailing_me_total else null, th);
}
if (!has_total) {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() });
}
if (self.risk_metrics) |tr| {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ "", "Volatility", "Sharpe", "Max DD" }), .style = th.mutedStyle() });
const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year };
const risk_labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" };
for (0..4) |i| {
if (risk_arr[i]) |rm| {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {d:>13.1}% {d:>14.2} {d:>13.1}%", .{
risk_labels[i], rm.volatility * 100.0, rm.sharpe, rm.max_drawdown * 100.0,
}), .style = th.contentStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{
risk_labels[i], "", "", "",
}), .style = th.mutedStyle() });
}
}
}
return lines.toOwnedSlice(arena);
}
fn appendStyledReturnsTable(
arena: std.mem.Allocator,
lines: *std.ArrayList(StyledLine),
price: zfin.performance.TrailingReturns,
total: ?zfin.performance.TrailingReturns,
th: theme_mod.Theme,
) !void {
const has_total = total != null;
if (has_total) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}", .{ "", "Price Only", "Total Return" }), .style = th.mutedStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{ "", "Price Only" }), .style = th.mutedStyle() });
}
const price_arr = [4]?zfin.performance.PerformanceResult{ price.one_year, price.three_year, price.five_year, price.ten_year };
const total_arr_vals: [4]?zfin.performance.PerformanceResult = if (total) |t|
.{ t.one_year, t.three_year, t.five_year, t.ten_year }
else
.{ null, null, null, null };
const labels = [4][]const u8{ "1-Year Return:", "3-Year Return:", "5-Year Return:", "10-Year Return:" };
const annualize = [4]bool{ false, true, true, true };
for (0..4) |i| {
var price_buf: [32]u8 = undefined;
var total_buf: [32]u8 = undefined;
const row = fmt.fmtReturnsRow(
&price_buf,
&total_buf,
price_arr[i],
if (has_total) total_arr_vals[i] else null,
annualize[i],
);
const row_style = if (price_arr[i] != null)
(if (row.price_positive) th.positiveStyle() else th.negativeStyle())
else
th.mutedStyle();
if (has_total) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}{s}", .{ labels[i], row.price_str, row.total_str orelse "N/A", row.suffix }), .style = row_style });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}{s}", .{ labels[i], row.price_str, row.suffix }), .style = row_style });
}
}
}

1053
src/tui/portfolio_tab.zig Normal file

File diff suppressed because it is too large Load diff

567
src/tui/quote_tab.zig Normal file
View file

@ -0,0 +1,567 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme_mod = @import("theme.zig");
const chart_mod = @import("chart.zig");
const tui = @import("../tui.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
const glyph = tui.glyph;
// Rendering
/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
/// falling back to braille sparkline otherwise.
pub fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
// Determine whether to use Kitty graphics
const use_kitty = switch (self.chart_config.mode) {
.braille => false,
.kitty => true,
.auto => if (self.vx_app) |va| va.vx.caps.kitty_graphics else false,
};
if (use_kitty and self.candles != null and self.candles.?.len >= 40) {
drawWithKittyChart(self, ctx, buf, width, height) catch {
// On any failure, fall back to braille
try self.drawStyledContent(arena, buf, width, height, try buildStyledLines(self, arena));
};
} else {
// Fallback to styled lines with braille chart
try self.drawStyledContent(arena, buf, width, height, try buildStyledLines(self, arena));
}
}
/// Draw quote tab using Kitty graphics protocol for the chart.
fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
const th = self.theme;
const c = self.candles orelse return;
// Build text header (symbol, price, change) first few lines
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Symbol + price header
if (self.quote) |q| {
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ self.symbol, q.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (q.previous_close > 0) {
const change = q.close - q.previous_close;
const pct = (change / q.previous_close) * 100.0;
var chg_buf: [64]u8 = undefined;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
}
} else if (c.len > 0) {
const last = c[c.len - 1];
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ self.symbol, last.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (c.len >= 2) {
const prev_close = c[c.len - 2].close;
const change = last.close - prev_close;
const pct = (change / prev_close) * 100.0;
var chg_buf: [64]u8 = undefined;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
}
}
// Timeframe selector line
{
var tf_buf: [80]u8 = undefined;
var tf_pos: usize = 0;
const prefix = " Chart: ";
@memcpy(tf_buf[tf_pos..][0..prefix.len], prefix);
tf_pos += prefix.len;
const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
for (timeframes) |tf| {
const lbl = tf.label();
if (tf == self.chart_timeframe) {
tf_buf[tf_pos] = '[';
tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
tf_pos += lbl.len;
tf_buf[tf_pos] = ']';
tf_pos += 1;
} else {
tf_buf[tf_pos] = ' ';
tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
tf_pos += lbl.len;
tf_buf[tf_pos] = ' ';
tf_pos += 1;
}
tf_buf[tf_pos] = ' ';
tf_pos += 1;
}
const hint = " ([ ] to change)";
@memcpy(tf_buf[tf_pos..][0..hint.len], hint);
tf_pos += hint.len;
self.chart_timeframe_row = lines.items.len; // track which row the timeframe line is on
try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Draw the text header
const header_lines = try lines.toOwnedSlice(arena);
try self.drawStyledContent(arena, buf, width, height, header_lines);
// Calculate chart area (below the header, leaving room for details below)
const header_rows: u16 = @intCast(@min(header_lines.len, height));
const detail_rows: u16 = 10; // reserve rows for quote details below chart
const chart_rows = height -| header_rows -| detail_rows;
if (chart_rows < 8) return; // not enough space
// Compute pixel dimensions from cell size
// cell_size may be 0 if terminal hasn't reported pixel dimensions yet
const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8;
const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16;
const label_cols: u16 = 10; // columns reserved for axis labels on the right
const chart_cols = width -| 2 -| label_cols; // 1 col left margin + label area on right
if (chart_cols == 0) return;
const px_w: u32 = @as(u32, chart_cols) * cell_w;
const px_h: u32 = @as(u32, chart_rows) * cell_h;
if (px_w < 100 or px_h < 100) return;
// Apply resolution cap from chart config
const capped_w = @min(px_w, self.chart_config.max_width);
const capped_h = @min(px_h, self.chart_config.max_height);
// Check if we need to re-render the chart image
const symbol_changed = self.chart_symbol_len != self.symbol.len or
!std.mem.eql(u8, self.chart_symbol[0..self.chart_symbol_len], self.symbol);
const tf_changed = self.chart_timeframe_rendered == null or self.chart_timeframe_rendered.? != self.chart_timeframe;
if (self.chart_dirty or symbol_changed or tf_changed) {
// Free old image
if (self.chart_image_id) |old_id| {
if (self.vx_app) |va| {
va.vx.freeImage(va.tty.writer(), old_id);
}
self.chart_image_id = null;
}
// Render and transmit use the app's main allocator, NOT the arena,
// because z2d allocates large pixel buffers that would bloat the arena.
if (self.vx_app) |va| {
const chart_result = chart_mod.renderChart(
self.allocator,
c,
self.chart_timeframe,
capped_w,
capped_h,
th,
) catch |err| {
self.chart_dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
self.setStatus(msg);
return;
};
defer self.allocator.free(chart_result.rgb_data);
// Base64-encode and transmit raw RGB data directly via Kitty protocol.
// This avoids the PNG encode file write file read PNG decode roundtrip.
const base64_enc = std.base64.standard.Encoder;
const b64_buf = self.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
self.chart_dirty = false;
self.setStatus("Chart: base64 alloc failed");
return;
};
defer self.allocator.free(b64_buf);
const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data);
const img = va.vx.transmitPreEncodedImage(
va.tty.writer(),
encoded,
chart_result.width,
chart_result.height,
.rgb,
) catch |err| {
self.chart_dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
self.setStatus(msg);
return;
};
self.chart_image_id = img.id;
self.chart_image_width = @intCast(chart_cols);
self.chart_image_height = chart_rows;
// Track what we rendered
const sym_len = @min(self.symbol.len, 16);
@memcpy(self.chart_symbol[0..sym_len], self.symbol[0..sym_len]);
self.chart_symbol_len = sym_len;
self.chart_timeframe_rendered = self.chart_timeframe;
self.chart_price_min = chart_result.price_min;
self.chart_price_max = chart_result.price_max;
self.chart_rsi_latest = chart_result.rsi_latest;
self.chart_dirty = false;
}
}
// Place the image in the cell buffer
if (self.chart_image_id) |img_id| {
// Place image at the first cell of the chart area
const chart_row_start: usize = header_rows;
const chart_col_start: usize = 1; // 1 col left margin
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
if (buf_idx < buf.len) {
buf[buf_idx] = .{
.char = .{ .grapheme = " " },
.style = th.contentStyle(),
.image = .{
.img_id = img_id,
.options = .{
.size = .{
.rows = self.chart_image_height,
.cols = self.chart_image_width,
},
.scale = .contain,
},
},
};
}
// Axis labels (terminal text in the right margin)
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
// Map these to terminal rows to position labels.
const img_rows = self.chart_image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, self.chart_image_width) + 1;
const label_style = th.mutedStyle();
if (label_col + 8 <= width and img_rows >= 4 and self.chart_price_max > self.chart_price_min) {
// Price axis labels evenly spaced across the price panel (top 72%)
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
const n_price_labels: usize = 5;
for (0..n_price_labels) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1));
const price_val = self.chart_price_max - frac * (self.chart_price_max - self.chart_price_min);
const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue;
var lbl_buf: [16]u8 = undefined;
const lbl = fmt.fmtMoneyAbs(&lbl_buf, price_val);
const start_idx = row * @as(usize, width) + label_col;
for (lbl, 0..) |ch, ci| {
const idx = start_idx + ci;
if (idx < buf.len and label_col + ci < width) {
buf[idx] = .{
.char = .{ .grapheme = glyph(ch) },
.style = label_style,
};
}
}
}
// RSI axis labels positioned within the RSI panel (bottom 20%, after 80% offset)
const rsi_panel_start_f = @as(f64, @floatFromInt(img_rows)) * 0.80;
const rsi_panel_h = @as(f64, @floatFromInt(img_rows)) * 0.20;
const rsi_labels = [_]struct { val: f64, label: []const u8 }{
.{ .val = 70, .label = "70" },
.{ .val = 50, .label = "50" },
.{ .val = 30, .label = "30" },
};
for (rsi_labels) |rl| {
// RSI maps 0-100 top-to-bottom within the RSI panel
const rsi_frac = 1.0 - (rl.val / 100.0);
const row_f = @as(f64, @floatFromInt(chart_row_start)) + rsi_panel_start_f + rsi_frac * rsi_panel_h;
const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue;
const start_idx = row * @as(usize, width) + label_col;
for (rl.label, 0..) |ch, ci| {
const idx = start_idx + ci;
if (idx < buf.len and label_col + ci < width) {
buf[idx] = .{
.char = .{ .grapheme = glyph(ch) },
.style = label_style,
};
}
}
}
}
// Render quote details below the chart image as styled text
const detail_start_row = header_rows + self.chart_image_height;
if (detail_start_row + 8 < height) {
var detail_lines: std.ArrayList(StyledLine) = .empty;
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const latest = c[c.len - 1];
const quote_data = self.quote;
const price = if (quote_data) |q| q.close else latest.close;
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
try buildDetailColumns(self, arena, &detail_lines, latest, quote_data, price, prev_close);
// Write detail lines into the buffer below the image
const detail_buf_start = detail_start_row * @as(usize, width);
const remaining_height = height - @as(u16, @intCast(detail_start_row));
const detail_slice = try detail_lines.toOwnedSlice(arena);
if (detail_buf_start < buf.len) {
try self.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice);
}
}
}
}
fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme;
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (self.symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
var ago_buf: [16]u8 = undefined;
if (self.quote != null and self.quote_timestamp > 0) {
const ago_str = fmt.fmtTimeAgo(&ago_buf, self.quote_timestamp);
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ self.symbol, ago_str }), .style = th.headerStyle() });
} else if (self.candle_last_date) |d| {
var cdate_buf: [10]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {s})", .{ self.symbol, d.format(&cdate_buf) }), .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{self.symbol}), .style = th.headerStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (self.candles == null and !self.perf_loaded) self.loadPerfData();
// Use stored real-time quote if available (fetched on manual refresh)
const quote_data = self.quote;
const c = self.candles orelse {
if (quote_data) |q| {
// No candle data but have a quote - show it
var qclose_buf: [24]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&qclose_buf, q.close)}), .style = th.contentStyle() });
{
var chg_buf: [64]u8 = undefined;
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
}
return lines.toOwnedSlice(arena);
}
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{self.symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
if (c.len == 0) {
try lines.append(arena, .{ .text = " No candle data.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
// Use real-time quote price if available, otherwise latest candle
const price = if (quote_data) |q| q.close else c[c.len - 1].close;
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
const latest = c[c.len - 1];
try buildDetailColumns(self, arena, &lines, latest, quote_data, price, prev_close);
// Braille sparkline chart of recent 60 trading days
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const chart_days: usize = @min(c.len, 60);
const chart_data = c[c.len - chart_days ..];
try tui.renderBrailleToStyledLines(arena, &lines, chart_data, th);
// Recent history table
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Recent History:", .style = th.headerStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() });
const start_idx = if (c.len > 20) c.len - 20 else 0;
for (c[start_idx..]) |candle| {
var row_buf: [128]u8 = undefined;
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change });
}
return lines.toOwnedSlice(arena);
}
// Quote detail columns (price/OHLCV | ETF stats | sectors | holdings)
const Column = struct {
texts: std.ArrayList([]const u8),
styles: std.ArrayList(vaxis.Style),
width: usize, // fixed column width for padding
fn init() Column {
return .{
.texts = .empty,
.styles = .empty,
.width = 0,
};
}
fn add(self: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void {
try self.texts.append(arena, text);
try self.styles.append(arena, style);
}
fn len(self: *const Column) usize {
return self.texts.items.len;
}
};
fn buildDetailColumns(
self: *App,
arena: std.mem.Allocator,
lines: *std.ArrayList(StyledLine),
latest: zfin.Candle,
quote_data: ?zfin.Quote,
price: f64,
prev_close: f64,
) !void {
const th = self.theme;
var date_buf: [10]u8 = undefined;
var close_buf: [24]u8 = undefined;
var vol_buf: [32]u8 = undefined;
// Column 1: Price/OHLCV
var col1 = Column.init();
col1.width = 30;
try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), th.contentStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&close_buf, price)}), th.contentStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Volume: {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), th.mutedStyle());
if (prev_close > 0) {
const change = price - prev_close;
const pct = (change / prev_close) * 100.0;
var chg_buf: [64]u8 = undefined;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), change_style);
}
// Columns 2-4: ETF profile (only for actual ETFs)
var col2 = Column.init(); // ETF stats
col2.width = 22;
var col3 = Column.init(); // Sectors
col3.width = 26;
var col4 = Column.init(); // Top holdings
col4.width = 30;
if (self.etf_profile) |profile| {
// Col 2: ETF key stats
try col2.add(arena, "ETF Profile", th.headerStyle());
if (profile.expense_ratio) |er| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Expense: {d:.2}%", .{er * 100.0}), th.contentStyle());
}
if (profile.net_assets) |na| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Assets: ${s}", .{std.mem.trimRight(u8, &fmt.fmtLargeNum(na), &.{' '})}), th.contentStyle());
}
if (profile.dividend_yield) |dy| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Yield: {d:.2}%", .{dy * 100.0}), th.contentStyle());
}
if (profile.total_holdings) |th_val| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Holdings: {d}", .{th_val}), th.mutedStyle());
}
// Col 3: Sector allocation
if (profile.sectors) |sectors| {
if (sectors.len > 0) {
try col3.add(arena, "Sectors", th.headerStyle());
const show = @min(sectors.len, 7);
for (sectors[0..show]) |sec| {
var title_buf: [64]u8 = undefined;
const title_name = fmt.toTitleCase(&title_buf, sec.name);
const name = if (title_name.len > 20) title_name[0..20] else title_name;
try col3.add(arena, try std.fmt.allocPrint(arena, " {d:>5.1}% {s}", .{ sec.weight * 100.0, name }), th.contentStyle());
}
}
}
// Col 4: Top holdings
if (profile.holdings) |holdings| {
if (holdings.len > 0) {
try col4.add(arena, "Top Holdings", th.headerStyle());
const show = @min(holdings.len, 7);
for (holdings[0..show]) |h| {
const sym_str = h.symbol orelse "--";
try col4.add(arena, try std.fmt.allocPrint(arena, " {s:>6} {d:>5.1}%", .{ sym_str, h.weight * 100.0 }), th.contentStyle());
}
}
}
}
// Merge all columns into grapheme-based StyledLines
const gap: usize = 3;
const bg_style = vaxis.Style{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(th.bg) };
const cols = [_]*const Column{ &col1, &col2, &col3, &col4 };
var max_rows: usize = 0;
for (cols) |col| max_rows = @max(max_rows, col.len());
// Total max width for allocation
const max_width = col1.width + gap + col2.width + gap + col3.width + gap + col4.width + 4;
for (0..max_rows) |ri| {
const graphemes = try arena.alloc([]const u8, max_width);
const col_styles = try arena.alloc(vaxis.Style, max_width);
var pos: usize = 0;
for (cols, 0..) |col, ci| {
if (ci > 0 and col.len() == 0) continue; // skip empty columns entirely
if (ci > 0) {
// Gap between columns
for (0..gap) |_| {
if (pos < max_width) {
graphemes[pos] = " ";
col_styles[pos] = bg_style;
pos += 1;
}
}
}
if (ri < col.len()) {
const text = col.texts.items[ri];
const style = col.styles.items[ri];
// Write text characters
for (0..@min(text.len, col.width)) |ci2| {
if (pos < max_width) {
graphemes[pos] = glyph(text[ci2]);
col_styles[pos] = style;
pos += 1;
}
}
// Pad to column width
if (text.len < col.width) {
for (0..col.width - text.len) |_| {
if (pos < max_width) {
graphemes[pos] = " ";
col_styles[pos] = bg_style;
pos += 1;
}
}
}
} else {
// Empty row in this column - pad full width
for (0..col.width) |_| {
if (pos < max_width) {
graphemes[pos] = " ";
col_styles[pos] = bg_style;
pos += 1;
}
}
}
}
try lines.append(arena, .{
.text = "",
.style = bg_style,
.graphemes = graphemes[0..pos],
.cell_styles = col_styles[0..pos],
});
}
}