remove stupid one-liners that are not called/rename self in tabs

This commit is contained in:
Emil Lerch 2026-03-20 08:29:45 -07:00
parent 621a8db0df
commit b718c1ae39
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 377 additions and 385 deletions

View file

@ -1035,11 +1035,11 @@ pub const App = struct {
.earnings => { .earnings => {
if (self.symbol.len == 0) return; if (self.symbol.len == 0) return;
if (self.earnings_disabled) return; if (self.earnings_disabled) return;
if (!self.earnings_loaded) self.loadEarningsData(); if (!self.earnings_loaded) earnings_tab.loadData(self);
}, },
.options => { .options => {
if (self.symbol.len == 0) return; if (self.symbol.len == 0) return;
if (!self.options_loaded) self.loadOptionsData(); if (!self.options_loaded) options_tab.loadData(self);
}, },
.analysis => { .analysis => {
if (!self.analysis_loaded) self.loadAnalysisData(); if (!self.analysis_loaded) self.loadAnalysisData();
@ -1063,14 +1063,6 @@ pub const App = struct {
perf_tab.loadData(self); perf_tab.loadData(self);
} }
fn loadEarningsData(self: *App) void {
earnings_tab.loadData(self);
}
fn loadOptionsData(self: *App) void {
options_tab.loadData(self);
}
pub fn setStatus(self: *App, msg: []const u8) void { pub fn setStatus(self: *App, msg: []const u8) void {
const len = @min(msg.len, self.status_msg.len); const len = @min(msg.len, self.status_msg.len);
@memcpy(self.status_msg[0..len], msg[0..len]); @memcpy(self.status_msg[0..len], msg[0..len]);

View file

@ -9,84 +9,84 @@ const StyledLine = tui.StyledLine;
// Data loading // Data loading
pub fn loadData(self: *App) void { pub fn loadData(app: *App) void {
self.analysis_loaded = true; app.analysis_loaded = true;
// Ensure portfolio is loaded first // Ensure portfolio is loaded first
if (!self.portfolio_loaded) self.loadPortfolioData(); if (!app.portfolio_loaded) app.loadPortfolioData();
const pf = self.portfolio orelse return; const pf = app.portfolio orelse return;
const summary = self.portfolio_summary orelse return; const summary = app.portfolio_summary orelse return;
// Load classification metadata file // Load classification metadata file
if (self.classification_map == null) { if (app.classification_map == null) {
// Look for metadata.srf next to the portfolio file // Look for metadata.srf next to the portfolio file
if (self.portfolio_path) |ppath| { if (app.portfolio_path) |ppath| {
// Derive metadata path: same directory as portfolio, named "metadata.srf" // 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 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; const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
defer self.allocator.free(meta_path); defer app.allocator.free(meta_path);
const file_data = std.fs.cwd().readFileAlloc(self.allocator, meta_path, 1024 * 1024) catch { const file_data = std.fs.cwd().readFileAlloc(app.allocator, meta_path, 1024 * 1024) catch {
self.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf"); app.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
return; return;
}; };
defer self.allocator.free(file_data); defer app.allocator.free(file_data);
self.classification_map = zfin.classification.parseClassificationFile(self.allocator, file_data) catch { app.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch {
self.setStatus("Error parsing metadata.srf"); app.setStatus("Error parsing metadata.srf");
return; return;
}; };
} }
} }
// Load account tax type metadata file (optional) // Load account tax type metadata file (optional)
if (self.account_map == null) { if (app.account_map == null) {
if (self.portfolio_path) |ppath| { if (app.portfolio_path) |ppath| {
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0; 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 { const acct_path = std.fmt.allocPrint(app.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch {
loadDataFinish(self, pf, summary); loadDataFinish(app, pf, summary);
return; return;
}; };
defer self.allocator.free(acct_path); defer app.allocator.free(acct_path);
if (std.fs.cwd().readFileAlloc(self.allocator, acct_path, 1024 * 1024)) |acct_data| { if (std.fs.cwd().readFileAlloc(app.allocator, acct_path, 1024 * 1024)) |acct_data| {
defer self.allocator.free(acct_data); defer app.allocator.free(acct_data);
self.account_map = zfin.analysis.parseAccountsFile(self.allocator, acct_data) catch null; app.account_map = zfin.analysis.parseAccountsFile(app.allocator, acct_data) catch null;
} else |_| { } else |_| {
// accounts.srf is optional -- analysis works without it // accounts.srf is optional -- analysis works without it
} }
} }
} }
loadDataFinish(self, pf, summary); loadDataFinish(app, pf, summary);
} }
fn loadDataFinish(self: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
const cm = self.classification_map orelse { const cm = app.classification_map orelse {
self.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf"); app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
return; return;
}; };
// Free previous result // Free previous result
if (self.analysis_result) |*ar| ar.deinit(self.allocator); if (app.analysis_result) |*ar| ar.deinit(app.allocator);
self.analysis_result = zfin.analysis.analyzePortfolio( app.analysis_result = zfin.analysis.analyzePortfolio(
self.allocator, app.allocator,
summary.allocations, summary.allocations,
cm, cm,
pf, pf,
summary.total_value, summary.total_value,
self.account_map, app.account_map,
) catch { ) catch {
self.setStatus("Error computing analysis"); app.setStatus("Error computing analysis");
return; return;
}; };
} }
// Rendering // Rendering
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
return renderAnalysisLines(arena, self.theme, self.analysis_result); return renderAnalysisLines(arena, app.theme, app.analysis_result);
} }
/// Render analysis tab content. Pure function no App dependency. /// Render analysis tab content. Pure function no App dependency.

View file

@ -9,23 +9,23 @@ const StyledLine = tui.StyledLine;
// Data loading // Data loading
pub fn loadData(self: *App) void { pub fn loadData(app: *App) void {
self.earnings_loaded = true; app.earnings_loaded = true;
self.freeEarnings(); app.freeEarnings();
const result = self.svc.getEarnings(self.symbol) catch |err| { const result = app.svc.getEarnings(app.symbol) catch |err| {
switch (err) { switch (err) {
zfin.DataError.NoApiKey => self.setStatus("No API key. Set FINNHUB_API_KEY"), zfin.DataError.NoApiKey => app.setStatus("No API key. Set FINNHUB_API_KEY"),
zfin.DataError.FetchFailed => { zfin.DataError.FetchFailed => {
self.earnings_disabled = true; app.earnings_disabled = true;
self.setStatus("No earnings data (ETF/index?)"); app.setStatus("No earnings data (ETF/index?)");
}, },
else => self.setStatus("Error loading earnings"), else => app.setStatus("Error loading earnings"),
} }
return; return;
}; };
self.earnings_data = result.data; app.earnings_data = result.data;
self.earnings_timestamp = result.timestamp; app.earnings_timestamp = result.timestamp;
// Sort chronologically (oldest first) providers may return in any order // Sort chronologically (oldest first) providers may return in any order
if (result.data.len > 1) { if (result.data.len > 1) {
@ -37,17 +37,17 @@ pub fn loadData(self: *App) void {
} }
if (result.data.len == 0) { if (result.data.len == 0) {
self.earnings_disabled = true; app.earnings_disabled = true;
self.setStatus("No earnings data available (ETF/index?)"); app.setStatus("No earnings data available (ETF/index?)");
return; return;
} }
self.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); app.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
} }
// Rendering // Rendering
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
return renderEarningsLines(arena, self.theme, self.symbol, self.earnings_disabled, self.earnings_data, self.earnings_timestamp); return renderEarningsLines(arena, app.theme, app.symbol, app.earnings_disabled, app.earnings_data, app.earnings_timestamp);
} }
/// Render earnings tab content. Pure function no App dependency. /// Render earnings tab content. Pure function no App dependency.

View file

@ -10,41 +10,41 @@ const StyledLine = tui.StyledLine;
// Data loading // Data loading
pub fn loadData(self: *App) void { pub fn loadData(app: *App) void {
self.options_loaded = true; app.options_loaded = true;
self.freeOptions(); app.freeOptions();
const result = self.svc.getOptions(self.symbol) catch |err| { const result = app.svc.getOptions(app.symbol) catch |err| {
switch (err) { switch (err) {
zfin.DataError.FetchFailed => self.setStatus("CBOE fetch failed (network error)"), zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"),
else => self.setStatus("Error loading options"), else => app.setStatus("Error loading options"),
} }
return; return;
}; };
self.options_data = result.data; app.options_data = result.data;
self.options_timestamp = result.timestamp; app.options_timestamp = result.timestamp;
self.options_cursor = 0; app.options_cursor = 0;
self.options_expanded = [_]bool{false} ** 64; app.options_expanded = [_]bool{false} ** 64;
self.options_calls_collapsed = [_]bool{false} ** 64; app.options_calls_collapsed = [_]bool{false} ** 64;
self.options_puts_collapsed = [_]bool{false} ** 64; app.options_puts_collapsed = [_]bool{false} ** 64;
self.rebuildOptionsRows(); app.rebuildOptionsRows();
self.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh"); app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh");
} }
// Rendering // Rendering
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme; const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty; var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (self.symbol.len == 0) { if (app.symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena); return lines.toOwnedSlice(arena);
} }
const chains = self.options_data orelse { const chains = app.options_data orelse {
try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() }); try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena); return lines.toOwnedSlice(arena);
}; };
@ -55,32 +55,32 @@ pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLin
} }
var opt_ago_buf: [16]u8 = undefined; var opt_ago_buf: [16]u8 = undefined;
const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, self.options_timestamp); const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, app.options_timestamp);
if (opt_ago.len > 0) { 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() }); 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 { } else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{self.symbol}), .style = th.headerStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{app.symbol}), .style = th.headerStyle() });
} }
if (chains[0].underlying_price) |price| { if (chains[0].underlying_price) |price| {
var price_buf: [24]u8 = undefined; 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 = 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() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Track header line count for mouse click mapping (after all non-data lines) // Track header line count for mouse click mapping (after all non-data lines)
self.options_header_lines = lines.items.len; app.options_header_lines = lines.items.len;
// Flat list of options rows with inline expand/collapse // Flat list of options rows with inline expand/collapse
for (self.options_rows.items, 0..) |row, ri| { for (app.options_rows.items, 0..) |row, ri| {
const is_cursor = ri == self.options_cursor; const is_cursor = ri == app.options_cursor;
switch (row.kind) { switch (row.kind) {
.expiration => { .expiration => {
if (row.exp_idx < chains.len) { if (row.exp_idx < chains.len) {
const chain = chains[row.exp_idx]; const chain = chains[row.exp_idx];
var db: [10]u8 = undefined; var db: [10]u8 = undefined;
const is_expanded = row.exp_idx < self.options_expanded.len and self.options_expanded[row.exp_idx]; 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 is_monthly = fmt.isMonthlyExpiration(chain.expiration);
const arrow: []const u8 = if (is_expanded) "v " else "> "; const arrow: []const u8 = if (is_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{ const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{
@ -94,7 +94,7 @@ pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLin
} }
}, },
.calls_header => { .calls_header => {
const calls_collapsed = row.exp_idx < self.options_calls_collapsed.len and self.options_calls_collapsed[row.exp_idx]; 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 arrow: []const u8 = if (calls_collapsed) " > " else " v ";
const style = if (is_cursor) th.selectStyle() else th.headerStyle(); 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", .{ 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", .{
@ -102,7 +102,7 @@ pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLin
}), .style = style }); }), .style = style });
}, },
.puts_header => { .puts_header => {
const puts_collapsed = row.exp_idx < self.options_puts_collapsed.len and self.options_puts_collapsed[row.exp_idx]; 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 "; const arrow: []const u8 = if (puts_collapsed) " > " else " v ";
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const style = if (is_cursor) th.selectStyle() else th.headerStyle(); const style = if (is_cursor) th.selectStyle() else th.headerStyle();

View file

@ -10,116 +10,116 @@ const StyledLine = tui.StyledLine;
// Data loading // Data loading
pub fn loadData(self: *App) void { pub fn loadData(app: *App) void {
self.perf_loaded = true; app.perf_loaded = true;
self.freeCandles(); app.freeCandles();
self.freeDividends(); app.freeDividends();
self.trailing_price = null; app.trailing_price = null;
self.trailing_total = null; app.trailing_total = null;
self.trailing_me_price = null; app.trailing_me_price = null;
self.trailing_me_total = null; app.trailing_me_total = null;
self.candle_count = 0; app.candle_count = 0;
self.candle_first_date = null; app.candle_first_date = null;
self.candle_last_date = null; app.candle_last_date = null;
const candle_result = self.svc.getCandles(self.symbol) catch |err| { const candle_result = app.svc.getCandles(app.symbol) catch |err| {
switch (err) { switch (err) {
zfin.DataError.NoApiKey => self.setStatus("No API key. Set TWELVEDATA_API_KEY"), zfin.DataError.NoApiKey => app.setStatus("No API key. Set TWELVEDATA_API_KEY"),
zfin.DataError.FetchFailed => self.setStatus("Fetch failed (network error or rate limit)"), zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"),
else => self.setStatus("Error loading data"), else => app.setStatus("Error loading data"),
} }
return; return;
}; };
self.candles = candle_result.data; app.candles = candle_result.data;
self.candle_timestamp = candle_result.timestamp; app.candle_timestamp = candle_result.timestamp;
const c = self.candles.?; const c = app.candles.?;
if (c.len == 0) { if (c.len == 0) {
self.setStatus("No data available for symbol"); app.setStatus("No data available for symbol");
return; return;
} }
self.candle_count = c.len; app.candle_count = c.len;
self.candle_first_date = c[0].date; app.candle_first_date = c[0].date;
self.candle_last_date = c[c.len - 1].date; app.candle_last_date = c[c.len - 1].date;
const today = fmt.todayDate(); const today = fmt.todayDate();
self.trailing_price = zfin.performance.trailingReturns(c); app.trailing_price = zfin.performance.trailingReturns(c);
self.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today); app.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today);
if (self.svc.getDividends(self.symbol)) |div_result| { if (app.svc.getDividends(app.symbol)) |div_result| {
self.dividends = div_result.data; app.dividends = div_result.data;
self.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data); app.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data);
self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); app.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
} else |_| {} } else |_| {}
self.risk_metrics = zfin.risk.trailingRisk(c); app.risk_metrics = zfin.risk.trailingRisk(c);
// Try to load ETF profile (non-fatal, won't show for non-ETFs) // Try to load ETF profile (non-fatal, won't show for non-ETFs)
if (!self.etf_loaded) { if (!app.etf_loaded) {
self.etf_loaded = true; app.etf_loaded = true;
if (self.svc.getEtfProfile(self.symbol)) |etf_result| { if (app.svc.getEtfProfile(app.symbol)) |etf_result| {
if (etf_result.data.isEtf()) { if (etf_result.data.isEtf()) {
self.etf_profile = etf_result.data; app.etf_profile = etf_result.data;
} }
} else |_| {} } else |_| {}
} }
self.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); app.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
} }
// Rendering // Rendering
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme; const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty; var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (self.symbol.len == 0) { if (app.symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena); return lines.toOwnedSlice(arena);
} }
if (self.candle_last_date) |d| { if (app.candle_last_date) |d| {
var pdate_buf: [10]u8 = undefined; 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() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {s})", .{ app.symbol, d.format(&pdate_buf) }), .style = th.headerStyle() });
} else { } else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{self.symbol}), .style = th.headerStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{app.symbol}), .style = th.headerStyle() });
} }
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (self.trailing_price == null) { if (app.trailing_price == null) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{self.symbol}), .style = th.mutedStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena); return lines.toOwnedSlice(arena);
} }
if (self.candle_count > 0) { if (app.candle_count > 0) {
if (self.candle_first_date) |first| { if (app.candle_first_date) |first| {
if (self.candle_last_date) |last| { if (app.candle_last_date) |last| {
var fb: [10]u8 = undefined; var fb: [10]u8 = undefined;
var lb: [10]u8 = undefined; var lb: [10]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({s} to {s})", .{ 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), app.candle_count, first.format(&fb), last.format(&lb),
}), .style = th.mutedStyle() }); }), .style = th.mutedStyle() });
} }
} }
} }
if (self.candles) |cc| { if (app.candles) |cc| {
if (cc.len > 0) { if (cc.len > 0) {
var close_buf: [24]u8 = undefined; 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() }); 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; const has_total = app.trailing_total != null;
if (self.candle_last_date) |last| { if (app.candle_last_date) |last| {
var db: [10]u8 = undefined; var db: [10]u8 = undefined;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); 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 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); try appendStyledReturnsTable(arena, &lines, app.trailing_price.?, if (has_total) app.trailing_total else null, th);
{ {
const today = fmt.todayDate(); const today = fmt.todayDate();
@ -128,8 +128,8 @@ pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLin
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); 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() }); 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| { if (app.trailing_me_price) |me_price| {
try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) self.trailing_me_total else null, th); try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.trailing_me_total else null, th);
} }
if (!has_total) { if (!has_total) {
@ -137,7 +137,7 @@ pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLin
try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() }); try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() });
} }
if (self.risk_metrics) |tr| { if (app.risk_metrics) |tr| {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); 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 = " 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() }); 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() });

View file

@ -44,27 +44,27 @@ const gl_col_start: usize = col_end_market_value;
/// On first call, uses prefetched_prices (populated before TUI started). /// On first call, uses prefetched_prices (populated before TUI started).
/// On refresh, fetches live via svc.loadPrices. Tab switching skips this /// On refresh, fetches live via svc.loadPrices. Tab switching skips this
/// entirely because the portfolio_loaded guard in loadTabData() short-circuits. /// entirely because the portfolio_loaded guard in loadTabData() short-circuits.
pub fn loadPortfolioData(self: *App) void { pub fn loadPortfolioData(app: *App) void {
self.portfolio_loaded = true; app.portfolio_loaded = true;
self.freePortfolioSummary(); app.freePortfolioSummary();
const pf = self.portfolio orelse return; const pf = app.portfolio orelse return;
const positions = pf.positions(self.allocator) catch { const positions = pf.positions(app.allocator) catch {
self.setStatus("Error computing positions"); app.setStatus("Error computing positions");
return; return;
}; };
defer self.allocator.free(positions); defer app.allocator.free(positions);
var prices = std.StringHashMap(f64).init(self.allocator); var prices = std.StringHashMap(f64).init(app.allocator);
defer prices.deinit(); defer prices.deinit();
// Only fetch prices for stock/ETF symbols (skip options, CDs, cash) // Only fetch prices for stock/ETF symbols (skip options, CDs, cash)
const syms = pf.stockSymbols(self.allocator) catch { const syms = pf.stockSymbols(app.allocator) catch {
self.setStatus("Error getting symbols"); app.setStatus("Error getting symbols");
return; return;
}; };
defer self.allocator.free(syms); defer app.allocator.free(syms);
var latest_date: ?zfin.Date = null; var latest_date: ?zfin.Date = null;
var fail_count: usize = 0; var fail_count: usize = 0;
@ -72,7 +72,7 @@ pub fn loadPortfolioData(self: *App) void {
var stale_count: usize = 0; var stale_count: usize = 0;
var failed_syms: [8][]const u8 = undefined; var failed_syms: [8][]const u8 = undefined;
if (self.prefetched_prices) |*pp| { if (app.prefetched_prices) |*pp| {
// Use pre-fetched prices from before TUI started (first load only) // Use pre-fetched prices from before TUI started (first load only)
// Move stock prices into the working map // Move stock prices into the working map
for (syms) |sym| { for (syms) |sym| {
@ -82,10 +82,10 @@ pub fn loadPortfolioData(self: *App) void {
} }
// Extract watchlist prices // Extract watchlist prices
if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
self.watchlist_prices = std.StringHashMap(f64).init(self.allocator); app.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
} }
var wp = &(self.watchlist_prices.?); var wp = &(app.watchlist_prices.?);
var pp_iter = pp.iterator(); var pp_iter = pp.iterator();
while (pp_iter.next()) |entry| { while (pp_iter.next()) |entry| {
if (!prices.contains(entry.key_ptr.*)) { if (!prices.contains(entry.key_ptr.*)) {
@ -94,17 +94,17 @@ pub fn loadPortfolioData(self: *App) void {
} }
pp.deinit(); pp.deinit();
self.prefetched_prices = null; app.prefetched_prices = null;
} else { } else {
// Live fetch (refresh path) fetch watchlist first, then stock prices // Live fetch (refresh path) fetch watchlist first, then stock prices
if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
self.watchlist_prices = std.StringHashMap(f64).init(self.allocator); app.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
} }
var wp = &(self.watchlist_prices.?); var wp = &(app.watchlist_prices.?);
if (self.watchlist) |wl| { if (app.watchlist) |wl| {
for (wl) |sym| { for (wl) |sym| {
const result = self.svc.getCandles(sym) catch continue; const result = app.svc.getCandles(sym) catch continue;
defer self.allocator.free(result.data); defer app.allocator.free(result.data);
if (result.data.len > 0) { if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {}; wp.put(sym, result.data[result.data.len - 1].close) catch {};
} }
@ -113,8 +113,8 @@ pub fn loadPortfolioData(self: *App) void {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .watch) { if (lot.security_type == .watch) {
const sym = lot.priceSymbol(); const sym = lot.priceSymbol();
const result = self.svc.getCandles(sym) catch continue; const result = app.svc.getCandles(sym) catch continue;
defer self.allocator.free(result.data); defer app.allocator.free(result.data);
if (result.data.len > 0) { if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {}; wp.put(sym, result.data[result.data.len - 1].close) catch {};
} }
@ -152,46 +152,46 @@ pub fn loadPortfolioData(self: *App) void {
}; };
} }
}; };
var tui_progress = TuiProgress{ .app = self, .failed = &failed_syms }; var tui_progress = TuiProgress{ .app = app, .failed = &failed_syms };
const load_result = self.svc.loadPrices(syms, &prices, false, tui_progress.callback()); const load_result = app.svc.loadPrices(syms, &prices, false, tui_progress.callback());
latest_date = load_result.latest_date; latest_date = load_result.latest_date;
fail_count = load_result.fail_count; fail_count = load_result.fail_count;
fetch_count = load_result.fetched_count; fetch_count = load_result.fetched_count;
stale_count = load_result.stale_count; stale_count = load_result.stale_count;
} }
self.candle_last_date = latest_date; app.candle_last_date = latest_date;
// Build portfolio summary, candle map, and historical snapshots // Build portfolio summary, candle map, and historical snapshots
var pf_data = cli.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc) catch |err| switch (err) { var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc) catch |err| switch (err) {
error.NoAllocations => { error.NoAllocations => {
self.setStatus("No cached prices. Run: zfin perf <SYMBOL> first"); app.setStatus("No cached prices. Run: zfin perf <SYMBOL> first");
return; return;
}, },
error.SummaryFailed => { error.SummaryFailed => {
self.setStatus("Error computing portfolio summary"); app.setStatus("Error computing portfolio summary");
return; return;
}, },
else => { else => {
self.setStatus("Error building portfolio data"); app.setStatus("Error building portfolio data");
return; return;
}, },
}; };
// Transfer ownership: summary stored on App, candle_map freed after snapshots extracted // Transfer ownership: summary stored on App, candle_map freed after snapshots extracted
self.portfolio_summary = pf_data.summary; app.portfolio_summary = pf_data.summary;
self.historical_snapshots = pf_data.snapshots; app.historical_snapshots = pf_data.snapshots;
{ {
// Free candle_map values and map (snapshots are value types, already copied) // Free candle_map values and map (snapshots are value types, already copied)
var it = pf_data.candle_map.valueIterator(); var it = pf_data.candle_map.valueIterator();
while (it.next()) |v| self.allocator.free(v.*); while (it.next()) |v| app.allocator.free(v.*);
pf_data.candle_map.deinit(); pf_data.candle_map.deinit();
} }
sortPortfolioAllocations(self); sortPortfolioAllocations(app);
rebuildPortfolioRows(self); rebuildPortfolioRows(app);
const summary = pf_data.summary; const summary = pf_data.summary;
if (self.symbol.len == 0 and summary.allocations.len > 0) { if (app.symbol.len == 0 and summary.allocations.len > 0) {
self.setActiveSymbol(summary.allocations[0].symbol); app.setActiveSymbol(summary.allocations[0].symbol);
} }
// Show warning if any securities failed to load // Show warning if any securities failed to load
@ -217,31 +217,31 @@ pub fn loadPortfolioData(self: *App) void {
} }
if (stale_count > 0) { if (stale_count > 0) {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to refresh: {s} (using stale cache)", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed"; const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to refresh: {s} (using stale cache)", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
self.setStatus(warn_msg); app.setStatus(warn_msg);
} else { } else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed"; const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
self.setStatus(warn_msg); app.setStatus(warn_msg);
} }
} else { } else {
if (stale_count > 0 and stale_count == fail_count) { if (stale_count > 0 and stale_count == fail_count) {
const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache"; const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache";
self.setStatus(warn_msg); app.setStatus(warn_msg);
} else { } else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed"; const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed";
self.setStatus(warn_msg); app.setStatus(warn_msg);
} }
} }
} else if (fetch_count > 0) { } else if (fetch_count > 0) {
var info_buf: [128]u8 = undefined; var info_buf: [128]u8 = undefined;
const info_msg = std.fmt.bufPrint(&info_buf, "Loaded {d} symbols ({d} fetched) | r/F5 to refresh", .{ syms.len, fetch_count }) catch "Loaded | r/F5 to refresh"; const info_msg = std.fmt.bufPrint(&info_buf, "Loaded {d} symbols ({d} fetched) | r/F5 to refresh", .{ syms.len, fetch_count }) catch "Loaded | r/F5 to refresh";
self.setStatus(info_msg); app.setStatus(info_msg);
} else { } else {
self.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help"); app.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help");
} }
} }
pub fn sortPortfolioAllocations(self: *App) void { pub fn sortPortfolioAllocations(app: *App) void {
if (self.portfolio_summary) |s| { if (app.portfolio_summary) |s| {
const SortCtx = struct { const SortCtx = struct {
field: PortfolioSortField, field: PortfolioSortField,
dir: tui.SortDirection, dir: tui.SortDirection,
@ -261,24 +261,24 @@ pub fn sortPortfolioAllocations(self: *App) void {
}; };
} }
}; };
std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = self.portfolio_sort_field, .dir = self.portfolio_sort_dir }, SortCtx.lessThan); std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = app.portfolio_sort_field, .dir = app.portfolio_sort_dir }, SortCtx.lessThan);
} }
} }
pub fn rebuildPortfolioRows(self: *App) void { pub fn rebuildPortfolioRows(app: *App) void {
self.portfolio_rows.clearRetainingCapacity(); app.portfolio_rows.clearRetainingCapacity();
if (self.portfolio_summary) |s| { if (app.portfolio_summary) |s| {
for (s.allocations, 0..) |a, i| { for (s.allocations, 0..) |a, i| {
// Count lots for this symbol // Count lots for this symbol
var lcount: usize = 0; var lcount: usize = 0;
if (self.portfolio) |pf| { if (app.portfolio) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) lcount += 1; if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) lcount += 1;
} }
} }
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .position, .kind = .position,
.symbol = a.symbol, .symbol = a.symbol,
.pos_idx = i, .pos_idx = i,
@ -286,14 +286,14 @@ pub fn rebuildPortfolioRows(self: *App) void {
}) catch continue; }) catch continue;
// Only expand if multi-lot // Only expand if multi-lot
if (lcount > 1 and i < self.expanded.len and self.expanded[i]) { if (lcount > 1 and i < app.expanded.len and app.expanded[i]) {
if (self.portfolio) |pf| { if (app.portfolio) |pf| {
// Collect matching lots, sort: open first (date desc), then closed (date desc) // Collect matching lots, sort: open first (date desc), then closed (date desc)
var matching: std.ArrayList(zfin.Lot) = .empty; var matching: std.ArrayList(zfin.Lot) = .empty;
defer matching.deinit(self.allocator); defer matching.deinit(app.allocator);
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
matching.append(self.allocator, lot) catch continue; matching.append(app.allocator, lot) catch continue;
} }
} }
std.mem.sort(zfin.Lot, matching.items, {}, fmt.lotSortFn); std.mem.sort(zfin.Lot, matching.items, {}, fmt.lotSortFn);
@ -310,7 +310,7 @@ pub fn rebuildPortfolioRows(self: *App) void {
if (!has_drip) { if (!has_drip) {
// No DRIP lots: show all individually // No DRIP lots: show all individually
for (matching.items) |lot| { for (matching.items) |lot| {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .lot, .kind = .lot,
.symbol = lot.symbol, .symbol = lot.symbol,
.pos_idx = i, .pos_idx = i,
@ -321,7 +321,7 @@ pub fn rebuildPortfolioRows(self: *App) void {
// Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT // Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT
for (matching.items) |lot| { for (matching.items) |lot| {
if (!lot.drip) { if (!lot.drip) {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .lot, .kind = .lot,
.symbol = lot.symbol, .symbol = lot.symbol,
.pos_idx = i, .pos_idx = i,
@ -334,7 +334,7 @@ pub fn rebuildPortfolioRows(self: *App) void {
const drip = fmt.aggregateDripLots(matching.items); const drip = fmt.aggregateDripLots(matching.items);
if (!drip.st.isEmpty()) { if (!drip.st.isEmpty()) {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .drip_summary, .kind = .drip_summary,
.symbol = a.symbol, .symbol = a.symbol,
.pos_idx = i, .pos_idx = i,
@ -347,7 +347,7 @@ pub fn rebuildPortfolioRows(self: *App) void {
}) catch {}; }) catch {};
} }
if (!drip.lt.isEmpty()) { if (!drip.lt.isEmpty()) {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .drip_summary, .kind = .drip_summary,
.symbol = a.symbol, .symbol = a.symbol,
.pos_idx = i, .pos_idx = i,
@ -367,23 +367,23 @@ pub fn rebuildPortfolioRows(self: *App) void {
// Add watchlist items from both the separate watchlist file and // Add watchlist items from both the separate watchlist file and
// watch lots embedded in the portfolio. Skip symbols already in allocations. // watch lots embedded in the portfolio. Skip symbols already in allocations.
var watch_seen = std.StringHashMap(void).init(self.allocator); var watch_seen = std.StringHashMap(void).init(app.allocator);
defer watch_seen.deinit(); defer watch_seen.deinit();
// Mark all portfolio position symbols as seen // Mark all portfolio position symbols as seen
if (self.portfolio_summary) |s| { if (app.portfolio_summary) |s| {
for (s.allocations) |a| { for (s.allocations) |a| {
watch_seen.put(a.symbol, {}) catch {}; watch_seen.put(a.symbol, {}) catch {};
} }
} }
// Watch lots from portfolio file // Watch lots from portfolio file
if (self.portfolio) |pf| { if (app.portfolio) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .watch) { if (lot.security_type == .watch) {
if (watch_seen.contains(lot.priceSymbol())) continue; if (watch_seen.contains(lot.priceSymbol())) continue;
watch_seen.put(lot.priceSymbol(), {}) catch {}; watch_seen.put(lot.priceSymbol(), {}) catch {};
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .watchlist, .kind = .watchlist,
.symbol = lot.symbol, .symbol = lot.symbol,
}) catch continue; }) catch continue;
@ -392,11 +392,11 @@ pub fn rebuildPortfolioRows(self: *App) void {
} }
// Separate watchlist file (backward compat) // Separate watchlist file (backward compat)
if (self.watchlist) |wl| { if (app.watchlist) |wl| {
for (wl) |sym| { for (wl) |sym| {
if (watch_seen.contains(sym)) continue; if (watch_seen.contains(sym)) continue;
watch_seen.put(sym, {}) catch {}; watch_seen.put(sym, {}) catch {};
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .watchlist, .kind = .watchlist,
.symbol = sym, .symbol = sym,
}) catch continue; }) catch continue;
@ -404,15 +404,15 @@ pub fn rebuildPortfolioRows(self: *App) void {
} }
// Options section // Options section
if (self.portfolio) |pf| { if (app.portfolio) |pf| {
if (pf.hasType(.option)) { if (pf.hasType(.option)) {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .section_header, .kind = .section_header,
.symbol = "Options", .symbol = "Options",
}) catch {}; }) catch {};
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .option) { if (lot.security_type == .option) {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .option_row, .kind = .option_row,
.symbol = lot.symbol, .symbol = lot.symbol,
.lot = lot, .lot = lot,
@ -423,20 +423,20 @@ pub fn rebuildPortfolioRows(self: *App) void {
// CDs section (sorted by maturity date, earliest first) // CDs section (sorted by maturity date, earliest first)
if (pf.hasType(.cd)) { if (pf.hasType(.cd)) {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .section_header, .kind = .section_header,
.symbol = "Certificates of Deposit", .symbol = "Certificates of Deposit",
}) catch {}; }) catch {};
var cd_lots: std.ArrayList(zfin.Lot) = .empty; var cd_lots: std.ArrayList(zfin.Lot) = .empty;
defer cd_lots.deinit(self.allocator); defer cd_lots.deinit(app.allocator);
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .cd) { if (lot.security_type == .cd) {
cd_lots.append(self.allocator, lot) catch continue; cd_lots.append(app.allocator, lot) catch continue;
} }
} }
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn); std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
for (cd_lots.items) |lot| { for (cd_lots.items) |lot| {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .cd_row, .kind = .cd_row,
.symbol = lot.symbol, .symbol = lot.symbol,
.lot = lot, .lot = lot,
@ -446,20 +446,20 @@ pub fn rebuildPortfolioRows(self: *App) void {
// Cash section (single total row, expandable to show per-account) // Cash section (single total row, expandable to show per-account)
if (pf.hasType(.cash)) { if (pf.hasType(.cash)) {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .section_header, .kind = .section_header,
.symbol = "Cash", .symbol = "Cash",
}) catch {}; }) catch {};
// Total cash row // Total cash row
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .cash_total, .kind = .cash_total,
.symbol = "CASH", .symbol = "CASH",
}) catch {}; }) catch {};
// Per-account cash rows (expanded when cash_total is toggled) // Per-account cash rows (expanded when cash_total is toggled)
if (self.cash_expanded) { if (app.cash_expanded) {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .cash) { if (lot.security_type == .cash) {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .cash_row, .kind = .cash_row,
.symbol = lot.account orelse "Unknown", .symbol = lot.account orelse "Unknown",
.lot = lot, .lot = lot,
@ -471,20 +471,20 @@ pub fn rebuildPortfolioRows(self: *App) void {
// Illiquid assets section (similar to cash: total row, expandable) // Illiquid assets section (similar to cash: total row, expandable)
if (pf.hasType(.illiquid)) { if (pf.hasType(.illiquid)) {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .section_header, .kind = .section_header,
.symbol = "Illiquid Assets", .symbol = "Illiquid Assets",
}) catch {}; }) catch {};
// Total illiquid row // Total illiquid row
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .illiquid_total, .kind = .illiquid_total,
.symbol = "ILLIQUID", .symbol = "ILLIQUID",
}) catch {}; }) catch {};
// Per-asset rows (expanded when illiquid_total is toggled) // Per-asset rows (expanded when illiquid_total is toggled)
if (self.illiquid_expanded) { if (app.illiquid_expanded) {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .illiquid) { if (lot.security_type == .illiquid) {
self.portfolio_rows.append(self.allocator, .{ app.portfolio_rows.append(app.allocator, .{
.kind = .illiquid_row, .kind = .illiquid_row,
.symbol = lot.symbol, .symbol = lot.symbol,
.lot = lot, .lot = lot,
@ -498,18 +498,18 @@ pub fn rebuildPortfolioRows(self: *App) void {
// Rendering // Rendering
pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const th = self.theme; const th = app.theme;
if (self.portfolio == null and self.watchlist == null) { if (app.portfolio == null and app.watchlist == null) {
try drawWelcomeScreen(self, arena, buf, width, height); try drawWelcomeScreen(app, arena, buf, width, height);
return; return;
} }
var lines: std.ArrayList(StyledLine) = .empty; var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (self.portfolio_summary) |s| { if (app.portfolio_summary) |s| {
var val_buf: [24]u8 = undefined; var val_buf: [24]u8 = undefined;
var cost_buf: [24]u8 = undefined; var cost_buf: [24]u8 = undefined;
var gl_buf: [24]u8 = undefined; var gl_buf: [24]u8 = undefined;
@ -524,14 +524,14 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
try lines.append(arena, .{ .text = summary_text, .style = summary_style }); try lines.append(arena, .{ .text = summary_text, .style = summary_style });
// "as of" date indicator // "as of" date indicator
if (self.candle_last_date) |d| { if (app.candle_last_date) |d| {
var asof_buf: [10]u8 = undefined; var asof_buf: [10]u8 = undefined;
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {s})", .{d.format(&asof_buf)}); const asof_text = try std.fmt.allocPrint(arena, " (as of close on {s})", .{d.format(&asof_buf)});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
} }
// Net Worth line (only if portfolio has illiquid assets) // Net Worth line (only if portfolio has illiquid assets)
if (self.portfolio) |pf| { if (app.portfolio) |pf| {
if (pf.hasType(.illiquid)) { if (pf.hasType(.illiquid)) {
const illiquid_total = pf.totalIlliquid(); const illiquid_total = pf.totalIlliquid();
const net_worth = s.total_value + illiquid_total; const net_worth = s.total_value + illiquid_total;
@ -547,7 +547,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
} }
// Historical portfolio value snapshots // Historical portfolio value snapshots
if (self.historical_snapshots) |snapshots| { if (app.historical_snapshots) |snapshots| {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Build a single-line summary: " Historical: 1M: +3.2% 3M: +8.1% 1Y: +22.4% 3Y: +45.1% 5Y: -- 10Y: --" // Build a single-line summary: " Historical: 1M: +3.2% 3M: +8.1% 1Y: +22.4% 3Y: +45.1% 5Y: -- 10Y: --"
var hist_parts: [6][]const u8 = undefined; var hist_parts: [6][]const u8 = undefined;
@ -562,7 +562,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
}); });
try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() }); try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() });
} }
} else if (self.portfolio != null) { } else if (app.portfolio != null) {
try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() }); try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() });
} else { } else {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -573,8 +573,8 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
// Column header (4-char prefix to match arrow(2)+star(2) in data rows) // Column header (4-char prefix to match arrow(2)+star(2) in data rows)
// Active sort column gets a sort indicator within the column width // Active sort column gets a sort indicator within the column width
const sf = self.portfolio_sort_field; const sf = app.portfolio_sort_field;
const si = self.portfolio_sort_dir.indicator(); const si = app.portfolio_sort_dir.indicator();
// Build column labels with indicator embedded in padding // Build column labels with indicator embedded in padding
// Left-aligned cols: "Name▲ " Right-aligned cols: " ▼Price" // Left-aligned cols: "Name▲ " Right-aligned cols: " ▼Price"
var sym_hdr_buf: [16]u8 = undefined; var sym_hdr_buf: [16]u8 = undefined;
@ -599,21 +599,21 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() }); try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() });
// Track header line count for mouse click mapping (after all header lines) // Track header line count for mouse click mapping (after all header lines)
self.portfolio_header_lines = lines.items.len; app.portfolio_header_lines = lines.items.len;
self.portfolio_line_count = 0; app.portfolio_line_count = 0;
// Data rows // Data rows
for (self.portfolio_rows.items, 0..) |row, ri| { for (app.portfolio_rows.items, 0..) |row, ri| {
const lines_before = lines.items.len; const lines_before = lines.items.len;
const is_cursor = ri == self.cursor; const is_cursor = ri == app.cursor;
const is_active_sym = std.mem.eql(u8, row.symbol, self.symbol); const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol);
switch (row.kind) { switch (row.kind) {
.position => { .position => {
if (self.portfolio_summary) |s| { if (app.portfolio_summary) |s| {
if (row.pos_idx < s.allocations.len) { if (row.pos_idx < s.allocations.len) {
const a = s.allocations[row.pos_idx]; const a = s.allocations[row.pos_idx];
const is_multi = row.lot_count > 1; const is_multi = row.lot_count > 1;
const is_expanded = is_multi and row.pos_idx < self.expanded.len and self.expanded[row.pos_idx]; const is_expanded = is_multi and row.pos_idx < app.expanded.len and app.expanded[row.pos_idx];
const arrow: []const u8 = if (!is_multi) " " else if (is_expanded) "v " else "> "; const arrow: []const u8 = if (!is_multi) " " else if (is_expanded) "v " else "> ";
const star: []const u8 = if (is_active_sym) "* " else " "; const star: []const u8 = if (is_active_sym) "* " else " ";
const pnl_pct = if (a.cost_basis > 0) (a.unrealized_gain_loss / a.cost_basis) * 100.0 else @as(f64, 0); const pnl_pct = if (a.cost_basis > 0) (a.unrealized_gain_loss / a.cost_basis) * 100.0 else @as(f64, 0);
@ -637,7 +637,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
var date_col: []const u8 = ""; var date_col: []const u8 = "";
var acct_col: []const u8 = ""; var acct_col: []const u8 = "";
if (!is_multi) { if (!is_multi) {
if (self.portfolio) |pf| { if (app.portfolio) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
const ds = lot.open_date.format(&pos_date_buf); const ds = lot.open_date.format(&pos_date_buf);
@ -650,7 +650,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
} }
} else { } else {
// Multi-lot: show account if all lots share the same one // Multi-lot: show account if all lots share the same one
if (self.portfolio) |pf| { if (app.portfolio) |pf| {
var common_acct: ?[]const u8 = null; var common_acct: ?[]const u8 = null;
var mixed = false; var mixed = false;
for (pf.lots) |lot| { for (pf.lots) |lot| {
@ -702,7 +702,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
var lot_gl_str: []const u8 = ""; var lot_gl_str: []const u8 = "";
var lot_mv_str: []const u8 = ""; var lot_mv_str: []const u8 = "";
var lot_positive = true; var lot_positive = true;
if (self.portfolio_summary) |s| { if (app.portfolio_summary) |s| {
if (row.pos_idx < s.allocations.len) { if (row.pos_idx < s.allocations.len) {
const price = s.allocations[row.pos_idx].current_price; const price = s.allocations[row.pos_idx].current_price;
const use_price = lot.close_price orelse price; const use_price = lot.close_price orelse price;
@ -740,7 +740,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
}, },
.watchlist => { .watchlist => {
var price_str3: [16]u8 = undefined; var price_str3: [16]u8 = undefined;
const ps: []const u8 = if (self.watchlist_prices) |wp| const ps: []const u8 = if (app.watchlist_prices) |wp|
(if (wp.get(row.symbol)) |p| fmt.fmtMoneyAbs(&price_str3, p) else "--") (if (wp.get(row.symbol)) |p| fmt.fmtMoneyAbs(&price_str3, p) else "--")
else else
"--"; "--";
@ -818,10 +818,10 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
} }
}, },
.cash_total => { .cash_total => {
if (self.portfolio) |pf| { if (app.portfolio) |pf| {
const total_cash = pf.totalCash(); const total_cash = pf.totalCash();
var cash_buf: [24]u8 = undefined; var cash_buf: [24]u8 = undefined;
const arrow3: []const u8 = if (self.cash_expanded) "v " else "> "; const arrow3: []const u8 = if (app.cash_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{ const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{
arrow3, arrow3,
fmt.fmtMoneyAbs(&cash_buf, total_cash), fmt.fmtMoneyAbs(&cash_buf, total_cash),
@ -840,10 +840,10 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
} }
}, },
.illiquid_total => { .illiquid_total => {
if (self.portfolio) |pf| { if (app.portfolio) |pf| {
const total_illiquid = pf.totalIlliquid(); const total_illiquid = pf.totalIlliquid();
var illiquid_buf: [24]u8 = undefined; var illiquid_buf: [24]u8 = undefined;
const arrow4: []const u8 = if (self.illiquid_expanded) "v " else "> "; const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {s:>14}", .{ const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {s:>14}", .{
arrow4, arrow4,
fmt.fmtMoneyAbs(&illiquid_buf, total_illiquid), fmt.fmtMoneyAbs(&illiquid_buf, total_illiquid),
@ -878,21 +878,21 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
// Map all styled lines produced by this row back to the row index // Map all styled lines produced by this row back to the row index
const lines_after = lines.items.len; const lines_after = lines.items.len;
for (lines_before..lines_after) |li| { for (lines_before..lines_after) |li| {
const map_idx = li - self.portfolio_header_lines; const map_idx = li - app.portfolio_header_lines;
if (map_idx < self.portfolio_line_to_row.len) { if (map_idx < app.portfolio_line_to_row.len) {
self.portfolio_line_to_row[map_idx] = ri; app.portfolio_line_to_row[map_idx] = ri;
} }
} }
self.portfolio_line_count = lines_after - self.portfolio_header_lines; app.portfolio_line_count = lines_after - app.portfolio_header_lines;
} }
// Render // Render
const start = @min(self.scroll_offset, if (lines.items.len > 0) lines.items.len - 1 else 0); const start = @min(app.scroll_offset, if (lines.items.len > 0) lines.items.len - 1 else 0);
try self.drawStyledContent(arena, buf, width, height, lines.items[start..]); try app.drawStyledContent(arena, buf, width, height, lines.items[start..]);
} }
fn drawWelcomeScreen(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const th = self.theme; const th = app.theme;
const welcome_lines = [_]StyledLine{ const welcome_lines = [_]StyledLine{
.{ .text = "", .style = th.contentStyle() }, .{ .text = "", .style = th.contentStyle() },
.{ .text = " zfin", .style = th.headerStyle() }, .{ .text = " zfin", .style = th.headerStyle() },
@ -919,71 +919,71 @@ fn drawWelcomeScreen(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
.{ .text = " symbol::VTI,shares::100,open_date::2024-01-15,open_price::220.50", .style = th.mutedStyle() }, .{ .text = " symbol::VTI,shares::100,open_date::2024-01-15,open_price::220.50", .style = th.mutedStyle() },
.{ .text = " symbol::AAPL,shares::50,open_date::2024-03-01,open_price::170.00", .style = th.mutedStyle() }, .{ .text = " symbol::AAPL,shares::50,open_date::2024-03-01,open_price::170.00", .style = th.mutedStyle() },
}; };
try self.drawStyledContent(arena, buf, width, height, &welcome_lines); try app.drawStyledContent(arena, buf, width, height, &welcome_lines);
} }
/// Reload portfolio file from disk without re-fetching prices. /// Reload portfolio file from disk without re-fetching prices.
/// Uses cached candle data to recompute summary. /// Uses cached candle data to recompute summary.
pub fn reloadPortfolioFile(self: *App) void { pub fn reloadPortfolioFile(app: *App) void {
// Re-read the portfolio file // Re-read the portfolio file
if (self.portfolio) |*pf| pf.deinit(); if (app.portfolio) |*pf| pf.deinit();
self.portfolio = null; app.portfolio = null;
if (self.portfolio_path) |path| { if (app.portfolio_path) |path| {
const file_data = std.fs.cwd().readFileAlloc(self.allocator, path, 10 * 1024 * 1024) catch { const file_data = std.fs.cwd().readFileAlloc(app.allocator, path, 10 * 1024 * 1024) catch {
self.setStatus("Error reading portfolio file"); app.setStatus("Error reading portfolio file");
return; return;
}; };
defer self.allocator.free(file_data); defer app.allocator.free(file_data);
if (zfin.cache.deserializePortfolio(self.allocator, file_data)) |pf| { if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| {
self.portfolio = pf; app.portfolio = pf;
} else |_| { } else |_| {
self.setStatus("Error parsing portfolio file"); app.setStatus("Error parsing portfolio file");
return; return;
} }
} else { } else {
self.setStatus("No portfolio file to reload"); app.setStatus("No portfolio file to reload");
return; return;
} }
// Reload watchlist file too (if separate) // Reload watchlist file too (if separate)
tui.freeWatchlist(self.allocator, self.watchlist); tui.freeWatchlist(app.allocator, app.watchlist);
self.watchlist = null; app.watchlist = null;
if (self.watchlist_path) |path| { if (app.watchlist_path) |path| {
self.watchlist = tui.loadWatchlist(self.allocator, path); app.watchlist = tui.loadWatchlist(app.allocator, path);
} }
// Recompute summary using cached prices (no network) // Recompute summary using cached prices (no network)
self.freePortfolioSummary(); app.freePortfolioSummary();
self.expanded = [_]bool{false} ** 64; app.expanded = [_]bool{false} ** 64;
self.cash_expanded = false; app.cash_expanded = false;
self.illiquid_expanded = false; app.illiquid_expanded = false;
self.cursor = 0; app.cursor = 0;
self.scroll_offset = 0; app.scroll_offset = 0;
self.portfolio_rows.clearRetainingCapacity(); app.portfolio_rows.clearRetainingCapacity();
const pf = self.portfolio orelse return; const pf = app.portfolio orelse return;
const positions = pf.positions(self.allocator) catch { const positions = pf.positions(app.allocator) catch {
self.setStatus("Error computing positions"); app.setStatus("Error computing positions");
return; return;
}; };
defer self.allocator.free(positions); defer app.allocator.free(positions);
var prices = std.StringHashMap(f64).init(self.allocator); var prices = std.StringHashMap(f64).init(app.allocator);
defer prices.deinit(); defer prices.deinit();
const syms = pf.stockSymbols(self.allocator) catch { const syms = pf.stockSymbols(app.allocator) catch {
self.setStatus("Error getting symbols"); app.setStatus("Error getting symbols");
return; return;
}; };
defer self.allocator.free(syms); defer app.allocator.free(syms);
var latest_date: ?zfin.Date = null; var latest_date: ?zfin.Date = null;
var missing: usize = 0; var missing: usize = 0;
for (syms) |sym| { for (syms) |sym| {
// Cache only no network // Cache only no network
const candles_slice = self.svc.getCachedCandles(sym); const candles_slice = app.svc.getCachedCandles(sym);
if (candles_slice) |cs| { if (candles_slice) |cs| {
defer self.allocator.free(cs); defer app.allocator.free(cs);
if (cs.len > 0) { if (cs.len > 0) {
prices.put(sym, cs[cs.len - 1].close) catch {}; prices.put(sym, cs[cs.len - 1].close) catch {};
const d = cs[cs.len - 1].date; const d = cs[cs.len - 1].date;
@ -993,50 +993,50 @@ pub fn reloadPortfolioFile(self: *App) void {
missing += 1; missing += 1;
} }
} }
self.candle_last_date = latest_date; app.candle_last_date = latest_date;
// Build portfolio summary, candle map, and historical snapshots from cache // Build portfolio summary, candle map, and historical snapshots from cache
var pf_data = cli.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc) catch |err| switch (err) { var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc) catch |err| switch (err) {
error.NoAllocations => { error.NoAllocations => {
self.setStatus("No cached prices available"); app.setStatus("No cached prices available");
return; return;
}, },
error.SummaryFailed => { error.SummaryFailed => {
self.setStatus("Error computing portfolio summary"); app.setStatus("Error computing portfolio summary");
return; return;
}, },
else => { else => {
self.setStatus("Error building portfolio data"); app.setStatus("Error building portfolio data");
return; return;
}, },
}; };
self.portfolio_summary = pf_data.summary; app.portfolio_summary = pf_data.summary;
self.historical_snapshots = pf_data.snapshots; app.historical_snapshots = pf_data.snapshots;
{ {
var it = pf_data.candle_map.valueIterator(); var it = pf_data.candle_map.valueIterator();
while (it.next()) |v| self.allocator.free(v.*); while (it.next()) |v| app.allocator.free(v.*);
pf_data.candle_map.deinit(); pf_data.candle_map.deinit();
} }
sortPortfolioAllocations(self); sortPortfolioAllocations(app);
rebuildPortfolioRows(self); rebuildPortfolioRows(app);
// Invalidate analysis data -- it holds pointers into old portfolio memory // Invalidate analysis data -- it holds pointers into old portfolio memory
if (self.analysis_result) |*ar| ar.deinit(self.allocator); if (app.analysis_result) |*ar| ar.deinit(app.allocator);
self.analysis_result = null; app.analysis_result = null;
self.analysis_loaded = false; app.analysis_loaded = false;
// If currently on the analysis tab, eagerly recompute so the user // If currently on the analysis tab, eagerly recompute so the user
// doesn't see an error message before switching away and back. // doesn't see an error message before switching away and back.
if (self.active_tab == .analysis) { if (app.active_tab == .analysis) {
self.loadAnalysisData(); app.loadAnalysisData();
} }
if (missing > 0) { if (missing > 0) {
var warn_buf: [128]u8 = undefined; var warn_buf: [128]u8 = undefined;
const warn_msg = std.fmt.bufPrint(&warn_buf, "Reloaded. {d} symbols missing cached prices", .{missing}) catch "Reloaded (some prices missing)"; const warn_msg = std.fmt.bufPrint(&warn_buf, "Reloaded. {d} symbols missing cached prices", .{missing}) catch "Reloaded (some prices missing)";
self.setStatus(warn_msg); app.setStatus(warn_msg);
} else { } else {
self.setStatus("Portfolio reloaded from disk"); app.setStatus("Portfolio reloaded from disk");
} }
} }

View file

@ -14,40 +14,40 @@ const glyph = tui.glyph;
/// Draw the quote tab content. Uses Kitty graphics for the chart when available, /// Draw the quote tab content. Uses Kitty graphics for the chart when available,
/// falling back to braille sparkline otherwise. /// falling back to braille sparkline otherwise.
pub fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena; const arena = ctx.arena;
// Determine whether to use Kitty graphics // Determine whether to use Kitty graphics
const use_kitty = switch (self.chart.config.mode) { const use_kitty = switch (app.chart.config.mode) {
.braille => false, .braille => false,
.kitty => true, .kitty => true,
.auto => if (self.vx_app) |va| va.vx.caps.kitty_graphics else false, .auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
}; };
if (use_kitty and self.candles != null and self.candles.?.len >= 40) { if (use_kitty and app.candles != null and app.candles.?.len >= 40) {
drawWithKittyChart(self, ctx, buf, width, height) catch { drawWithKittyChart(app, ctx, buf, width, height) catch {
// On any failure, fall back to braille // On any failure, fall back to braille
try self.drawStyledContent(arena, buf, width, height, try buildStyledLines(self, arena)); try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
}; };
} else { } else {
// Fallback to styled lines with braille chart // Fallback to styled lines with braille chart
try self.drawStyledContent(arena, buf, width, height, try buildStyledLines(self, arena)); try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
} }
} }
/// Draw quote tab using Kitty graphics protocol for the chart. /// 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 { fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena; const arena = ctx.arena;
const th = self.theme; const th = app.theme;
const c = self.candles orelse return; const c = app.candles orelse return;
// Build text header (symbol, price, change) first few lines // Build text header (symbol, price, change) first few lines
var lines: std.ArrayList(StyledLine) = .empty; var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Symbol + price header // Symbol + price header
if (self.quote) |q| { if (app.quote) |q| {
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ self.symbol, q.close }); const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ app.symbol, q.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (q.previous_close > 0) { if (q.previous_close > 0) {
const change = q.close - q.previous_close; const change = q.close - q.previous_close;
@ -58,7 +58,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
} }
} else if (c.len > 0) { } else if (c.len > 0) {
const last = c[c.len - 1]; const last = c[c.len - 1];
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ self.symbol, last.close }); const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ app.symbol, last.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (c.len >= 2) { if (c.len >= 2) {
const prev_close = c[c.len - 2].close; const prev_close = c[c.len - 2].close;
@ -80,7 +80,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
for (timeframes) |tf| { for (timeframes) |tf| {
const lbl = tf.label(); const lbl = tf.label();
if (tf == self.chart.timeframe) { if (tf == app.chart.timeframe) {
tf_buf[tf_pos] = '['; tf_buf[tf_pos] = '[';
tf_pos += 1; tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl); @memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
@ -101,7 +101,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
const hint = " ([ ] to change)"; const hint = " ([ ] to change)";
@memcpy(tf_buf[tf_pos..][0..hint.len], hint); @memcpy(tf_buf[tf_pos..][0..hint.len], hint);
tf_pos += hint.len; tf_pos += hint.len;
self.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on app.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 = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() });
} }
@ -109,7 +109,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
// Draw the text header // Draw the text header
const header_lines = try lines.toOwnedSlice(arena); const header_lines = try lines.toOwnedSlice(arena);
try self.drawStyledContent(arena, buf, width, height, header_lines); try app.drawStyledContent(arena, buf, width, height, header_lines);
// Calculate chart area (below the header, leaving room for details below) // Calculate chart area (below the header, leaving room for details below)
const header_rows: u16 = @intCast(@min(header_lines.len, height)); const header_rows: u16 = @intCast(@min(header_lines.len, height));
@ -129,51 +129,51 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
if (px_w < 100 or px_h < 100) return; if (px_w < 100 or px_h < 100) return;
// Apply resolution cap from chart config // Apply resolution cap from chart config
const capped_w = @min(px_w, self.chart.config.max_width); const capped_w = @min(px_w, app.chart.config.max_width);
const capped_h = @min(px_h, self.chart.config.max_height); const capped_h = @min(px_h, app.chart.config.max_height);
// Check if we need to re-render the chart image // Check if we need to re-render the chart image
const symbol_changed = self.chart.symbol_len != self.symbol.len or const symbol_changed = app.chart.symbol_len != app.symbol.len or
!std.mem.eql(u8, self.chart.symbol[0..self.chart.symbol_len], self.symbol); !std.mem.eql(u8, app.chart.symbol[0..app.chart.symbol_len], app.symbol);
const tf_changed = self.chart.timeframe_rendered == null or self.chart.timeframe_rendered.? != self.chart.timeframe; const tf_changed = app.chart.timeframe_rendered == null or app.chart.timeframe_rendered.? != app.chart.timeframe;
if (self.chart.dirty or symbol_changed or tf_changed) { if (app.chart.dirty or symbol_changed or tf_changed) {
// Free old image // Free old image
if (self.chart.image_id) |old_id| { if (app.chart.image_id) |old_id| {
if (self.vx_app) |va| { if (app.vx_app) |va| {
va.vx.freeImage(va.tty.writer(), old_id); va.vx.freeImage(va.tty.writer(), old_id);
} }
self.chart.image_id = null; app.chart.image_id = null;
} }
// Render and transmit use the app's main allocator, NOT the arena, // Render and transmit use the app's main allocator, NOT the arena,
// because z2d allocates large pixel buffers that would bloat the arena. // because z2d allocates large pixel buffers that would bloat the arena.
if (self.vx_app) |va| { if (app.vx_app) |va| {
const chart_result = chart_mod.renderChart( const chart_result = chart_mod.renderChart(
self.allocator, app.allocator,
c, c,
self.chart.timeframe, app.chart.timeframe,
capped_w, capped_w,
capped_h, capped_h,
th, th,
) catch |err| { ) catch |err| {
self.chart.dirty = false; app.chart.dirty = false;
var err_buf: [128]u8 = undefined; var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed"; const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
self.setStatus(msg); app.setStatus(msg);
return; return;
}; };
defer self.allocator.free(chart_result.rgb_data); defer app.allocator.free(chart_result.rgb_data);
// Base64-encode and transmit raw RGB data directly via Kitty protocol. // Base64-encode and transmit raw RGB data directly via Kitty protocol.
// This avoids the PNG encode file write file read PNG decode roundtrip. // This avoids the PNG encode file write file read PNG decode roundtrip.
const base64_enc = std.base64.standard.Encoder; const base64_enc = std.base64.standard.Encoder;
const b64_buf = self.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch { const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
self.chart.dirty = false; app.chart.dirty = false;
self.setStatus("Chart: base64 alloc failed"); app.setStatus("Chart: base64 alloc failed");
return; return;
}; };
defer self.allocator.free(b64_buf); defer app.allocator.free(b64_buf);
const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data); const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data);
const img = va.vx.transmitPreEncodedImage( const img = va.vx.transmitPreEncodedImage(
@ -183,31 +183,31 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
chart_result.height, chart_result.height,
.rgb, .rgb,
) catch |err| { ) catch |err| {
self.chart.dirty = false; app.chart.dirty = false;
var err_buf: [128]u8 = undefined; var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed"; const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
self.setStatus(msg); app.setStatus(msg);
return; return;
}; };
self.chart.image_id = img.id; app.chart.image_id = img.id;
self.chart.image_width = @intCast(chart_cols); app.chart.image_width = @intCast(chart_cols);
self.chart.image_height = chart_rows; app.chart.image_height = chart_rows;
// Track what we rendered // Track what we rendered
const sym_len = @min(self.symbol.len, 16); const sym_len = @min(app.symbol.len, 16);
@memcpy(self.chart.symbol[0..sym_len], self.symbol[0..sym_len]); @memcpy(app.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
self.chart.symbol_len = sym_len; app.chart.symbol_len = sym_len;
self.chart.timeframe_rendered = self.chart.timeframe; app.chart.timeframe_rendered = app.chart.timeframe;
self.chart.price_min = chart_result.price_min; app.chart.price_min = chart_result.price_min;
self.chart.price_max = chart_result.price_max; app.chart.price_max = chart_result.price_max;
self.chart.rsi_latest = chart_result.rsi_latest; app.chart.rsi_latest = chart_result.rsi_latest;
self.chart.dirty = false; app.chart.dirty = false;
} }
} }
// Place the image in the cell buffer // Place the image in the cell buffer
if (self.chart.image_id) |img_id| { if (app.chart.image_id) |img_id| {
// Place image at the first cell of the chart area // Place image at the first cell of the chart area
const chart_row_start: usize = header_rows; const chart_row_start: usize = header_rows;
const chart_col_start: usize = 1; // 1 col left margin const chart_col_start: usize = 1; // 1 col left margin
@ -220,8 +220,8 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
.img_id = img_id, .img_id = img_id,
.options = .{ .options = .{
.size = .{ .size = .{
.rows = self.chart.image_height, .rows = app.chart.image_height,
.cols = self.chart.image_width, .cols = app.chart.image_width,
}, },
.scale = .contain, .scale = .contain,
}, },
@ -232,17 +232,17 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
// Axis labels (terminal text in the right margin) // Axis labels (terminal text in the right margin)
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20% // The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
// Map these to terminal rows to position labels. // Map these to terminal rows to position labels.
const img_rows = self.chart.image_height; const img_rows = app.chart.image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, self.chart.image_width) + 1; const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.chart.image_width) + 1;
const label_style = th.mutedStyle(); const label_style = th.mutedStyle();
if (label_col + 8 <= width and img_rows >= 4 and self.chart.price_max > self.chart.price_min) { if (label_col + 8 <= width and img_rows >= 4 and app.chart.price_max > app.chart.price_min) {
// Price axis labels evenly spaced across the price panel (top 72%) // Price axis labels evenly spaced across the price panel (top 72%)
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72; const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
const n_price_labels: usize = 5; const n_price_labels: usize = 5;
for (0..n_price_labels) |i| { for (0..n_price_labels) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1)); 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 price_val = app.chart.price_max - frac * (app.chart.price_max - app.chart.price_min);
const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows; const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
const row: usize = @intFromFloat(@round(row_f)); const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue; if (row >= height) continue;
@ -290,58 +290,58 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
} }
// Render quote details below the chart image as styled text // Render quote details below the chart image as styled text
const detail_start_row = header_rows + self.chart.image_height; const detail_start_row = header_rows + app.chart.image_height;
if (detail_start_row + 8 < height) { if (detail_start_row + 8 < height) {
var detail_lines: std.ArrayList(StyledLine) = .empty; var detail_lines: std.ArrayList(StyledLine) = .empty;
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const latest = c[c.len - 1]; const latest = c[c.len - 1];
const quote_data = self.quote; const quote_data = app.quote;
const price = if (quote_data) |q| q.close else latest.close; 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); 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); try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close);
// Write detail lines into the buffer below the image // Write detail lines into the buffer below the image
const detail_buf_start = detail_start_row * @as(usize, width); const detail_buf_start = detail_start_row * @as(usize, width);
const remaining_height = height - @as(u16, @intCast(detail_start_row)); const remaining_height = height - @as(u16, @intCast(detail_start_row));
const detail_slice = try detail_lines.toOwnedSlice(arena); const detail_slice = try detail_lines.toOwnedSlice(arena);
if (detail_buf_start < buf.len) { if (detail_buf_start < buf.len) {
try self.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice); try app.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice);
} }
} }
} }
} }
fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme; const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty; var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (self.symbol.len == 0) { if (app.symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena); return lines.toOwnedSlice(arena);
} }
var ago_buf: [16]u8 = undefined; var ago_buf: [16]u8 = undefined;
if (self.quote != null and self.quote_timestamp > 0) { if (app.quote != null and app.quote_timestamp > 0) {
const ago_str = fmt.fmtTimeAgo(&ago_buf, self.quote_timestamp); const ago_str = fmt.fmtTimeAgo(&ago_buf, app.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() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ app.symbol, ago_str }), .style = th.headerStyle() });
} else if (self.candle_last_date) |d| { } else if (app.candle_last_date) |d| {
var cdate_buf: [10]u8 = undefined; 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() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {s})", .{ app.symbol, d.format(&cdate_buf) }), .style = th.headerStyle() });
} else { } else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{self.symbol}), .style = th.headerStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{app.symbol}), .style = th.headerStyle() });
} }
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (self.candles == null and !self.perf_loaded) self.loadPerfData(); if (app.candles == null and !app.perf_loaded) app.loadPerfData();
// Use stored real-time quote if available (fetched on manual refresh) // Use stored real-time quote if available (fetched on manual refresh)
const quote_data = self.quote; const quote_data = app.quote;
const c = self.candles orelse { const c = app.candles orelse {
if (quote_data) |q| { if (quote_data) |q| {
// No candle data but have a quote - show it // No candle data but have a quote - show it
var qclose_buf: [24]u8 = undefined; var qclose_buf: [24]u8 = undefined;
@ -353,7 +353,7 @@ fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
} }
return lines.toOwnedSlice(arena); 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() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena); return lines.toOwnedSlice(arena);
}; };
if (c.len == 0) { if (c.len == 0) {
@ -366,7 +366,7 @@ fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
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 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]; const latest = c[c.len - 1];
try buildDetailColumns(self, arena, &lines, latest, quote_data, price, prev_close); try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close);
// Braille sparkline chart of recent 60 trading days // Braille sparkline chart of recent 60 trading days
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -404,18 +404,18 @@ const Column = struct {
}; };
} }
fn add(self: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void { fn add(app: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void {
try self.texts.append(arena, text); try app.texts.append(arena, text);
try self.styles.append(arena, style); try app.styles.append(arena, style);
} }
fn len(self: *const Column) usize { fn len(app: *const Column) usize {
return self.texts.items.len; return app.texts.items.len;
} }
}; };
fn buildDetailColumns( fn buildDetailColumns(
self: *App, app: *App,
arena: std.mem.Allocator, arena: std.mem.Allocator,
lines: *std.ArrayList(StyledLine), lines: *std.ArrayList(StyledLine),
latest: zfin.Candle, latest: zfin.Candle,
@ -423,7 +423,7 @@ fn buildDetailColumns(
price: f64, price: f64,
prev_close: f64, prev_close: f64,
) !void { ) !void {
const th = self.theme; const th = app.theme;
var date_buf: [10]u8 = undefined; var date_buf: [10]u8 = undefined;
var close_buf: [24]u8 = undefined; var close_buf: [24]u8 = undefined;
var vol_buf: [32]u8 = undefined; var vol_buf: [32]u8 = undefined;
@ -453,7 +453,7 @@ fn buildDetailColumns(
var col4 = Column.init(); // Top holdings var col4 = Column.init(); // Top holdings
col4.width = 30; col4.width = 30;
if (self.etf_profile) |profile| { if (app.etf_profile) |profile| {
// Col 2: ETF key stats // Col 2: ETF key stats
try col2.add(arena, "ETF Profile", th.headerStyle()); try col2.add(arena, "ETF Profile", th.headerStyle());
if (profile.expense_ratio) |er| { if (profile.expense_ratio) |er| {