remove stupid one-liners that are not called/rename self in tabs
This commit is contained in:
parent
621a8db0df
commit
b718c1ae39
7 changed files with 377 additions and 385 deletions
12
src/tui.zig
12
src/tui.zig
|
|
@ -1035,11 +1035,11 @@ pub const App = struct {
|
|||
.earnings => {
|
||||
if (self.symbol.len == 0) return;
|
||||
if (self.earnings_disabled) return;
|
||||
if (!self.earnings_loaded) self.loadEarningsData();
|
||||
if (!self.earnings_loaded) earnings_tab.loadData(self);
|
||||
},
|
||||
.options => {
|
||||
if (self.symbol.len == 0) return;
|
||||
if (!self.options_loaded) self.loadOptionsData();
|
||||
if (!self.options_loaded) options_tab.loadData(self);
|
||||
},
|
||||
.analysis => {
|
||||
if (!self.analysis_loaded) self.loadAnalysisData();
|
||||
|
|
@ -1063,14 +1063,6 @@ pub const App = struct {
|
|||
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 {
|
||||
const len = @min(msg.len, self.status_msg.len);
|
||||
@memcpy(self.status_msg[0..len], msg[0..len]);
|
||||
|
|
|
|||
|
|
@ -9,84 +9,84 @@ const StyledLine = tui.StyledLine;
|
|||
|
||||
// ── Data loading ──────────────────────────────────────────────
|
||||
|
||||
pub fn loadData(self: *App) void {
|
||||
self.analysis_loaded = true;
|
||||
pub fn loadData(app: *App) void {
|
||||
app.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;
|
||||
if (!app.portfolio_loaded) app.loadPortfolioData();
|
||||
const pf = app.portfolio orelse return;
|
||||
const summary = app.portfolio_summary orelse return;
|
||||
|
||||
// Load classification metadata file
|
||||
if (self.classification_map == null) {
|
||||
if (app.classification_map == null) {
|
||||
// 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"
|
||||
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 meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
|
||||
defer app.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");
|
||||
const file_data = std.fs.cwd().readFileAlloc(app.allocator, meta_path, 1024 * 1024) catch {
|
||||
app.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
||||
return;
|
||||
};
|
||||
defer self.allocator.free(file_data);
|
||||
defer app.allocator.free(file_data);
|
||||
|
||||
self.classification_map = zfin.classification.parseClassificationFile(self.allocator, file_data) catch {
|
||||
self.setStatus("Error parsing metadata.srf");
|
||||
app.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch {
|
||||
app.setStatus("Error parsing metadata.srf");
|
||||
return;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Load account tax type metadata file (optional)
|
||||
if (self.account_map == null) {
|
||||
if (self.portfolio_path) |ppath| {
|
||||
if (app.account_map == null) {
|
||||
if (app.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);
|
||||
const acct_path = std.fmt.allocPrint(app.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch {
|
||||
loadDataFinish(app, pf, summary);
|
||||
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| {
|
||||
defer self.allocator.free(acct_data);
|
||||
self.account_map = zfin.analysis.parseAccountsFile(self.allocator, acct_data) catch null;
|
||||
if (std.fs.cwd().readFileAlloc(app.allocator, acct_path, 1024 * 1024)) |acct_data| {
|
||||
defer app.allocator.free(acct_data);
|
||||
app.account_map = zfin.analysis.parseAccountsFile(app.allocator, acct_data) catch null;
|
||||
} else |_| {
|
||||
// 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 {
|
||||
const cm = self.classification_map orelse {
|
||||
self.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
||||
fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
|
||||
const cm = app.classification_map orelse {
|
||||
app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
||||
return;
|
||||
};
|
||||
|
||||
// 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(
|
||||
self.allocator,
|
||||
app.analysis_result = zfin.analysis.analyzePortfolio(
|
||||
app.allocator,
|
||||
summary.allocations,
|
||||
cm,
|
||||
pf,
|
||||
summary.total_value,
|
||||
self.account_map,
|
||||
app.account_map,
|
||||
) catch {
|
||||
self.setStatus("Error computing analysis");
|
||||
app.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);
|
||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
return renderAnalysisLines(arena, app.theme, app.analysis_result);
|
||||
}
|
||||
|
||||
/// Render analysis tab content. Pure function — no App dependency.
|
||||
|
|
|
|||
|
|
@ -9,23 +9,23 @@ const StyledLine = tui.StyledLine;
|
|||
|
||||
// ── Data loading ──────────────────────────────────────────────
|
||||
|
||||
pub fn loadData(self: *App) void {
|
||||
self.earnings_loaded = true;
|
||||
self.freeEarnings();
|
||||
pub fn loadData(app: *App) void {
|
||||
app.earnings_loaded = true;
|
||||
app.freeEarnings();
|
||||
|
||||
const result = self.svc.getEarnings(self.symbol) catch |err| {
|
||||
const result = app.svc.getEarnings(app.symbol) catch |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 => {
|
||||
self.earnings_disabled = true;
|
||||
self.setStatus("No earnings data (ETF/index?)");
|
||||
app.earnings_disabled = true;
|
||||
app.setStatus("No earnings data (ETF/index?)");
|
||||
},
|
||||
else => self.setStatus("Error loading earnings"),
|
||||
else => app.setStatus("Error loading earnings"),
|
||||
}
|
||||
return;
|
||||
};
|
||||
self.earnings_data = result.data;
|
||||
self.earnings_timestamp = result.timestamp;
|
||||
app.earnings_data = result.data;
|
||||
app.earnings_timestamp = result.timestamp;
|
||||
|
||||
// Sort chronologically (oldest first) — providers may return in any order
|
||||
if (result.data.len > 1) {
|
||||
|
|
@ -37,17 +37,17 @@ pub fn loadData(self: *App) void {
|
|||
}
|
||||
|
||||
if (result.data.len == 0) {
|
||||
self.earnings_disabled = true;
|
||||
self.setStatus("No earnings data available (ETF/index?)");
|
||||
app.earnings_disabled = true;
|
||||
app.setStatus("No earnings data available (ETF/index?)");
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -10,41 +10,41 @@ const StyledLine = tui.StyledLine;
|
|||
|
||||
// ── Data loading ──────────────────────────────────────────────
|
||||
|
||||
pub fn loadData(self: *App) void {
|
||||
self.options_loaded = true;
|
||||
self.freeOptions();
|
||||
pub fn loadData(app: *App) void {
|
||||
app.options_loaded = true;
|
||||
app.freeOptions();
|
||||
|
||||
const result = self.svc.getOptions(self.symbol) catch |err| {
|
||||
const result = app.svc.getOptions(app.symbol) catch |err| {
|
||||
switch (err) {
|
||||
zfin.DataError.FetchFailed => self.setStatus("CBOE fetch failed (network error)"),
|
||||
else => self.setStatus("Error loading options"),
|
||||
zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"),
|
||||
else => app.setStatus("Error loading options"),
|
||||
}
|
||||
return;
|
||||
};
|
||||
self.options_data = result.data;
|
||||
self.options_timestamp = result.timestamp;
|
||||
self.options_cursor = 0;
|
||||
self.options_expanded = [_]bool{false} ** 64;
|
||||
self.options_calls_collapsed = [_]bool{false} ** 64;
|
||||
self.options_puts_collapsed = [_]bool{false} ** 64;
|
||||
self.rebuildOptionsRows();
|
||||
self.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh");
|
||||
app.options_data = result.data;
|
||||
app.options_timestamp = result.timestamp;
|
||||
app.options_cursor = 0;
|
||||
app.options_expanded = [_]bool{false} ** 64;
|
||||
app.options_calls_collapsed = [_]bool{false} ** 64;
|
||||
app.options_puts_collapsed = [_]bool{false} ** 64;
|
||||
app.rebuildOptionsRows();
|
||||
app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh");
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────
|
||||
|
||||
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
const th = self.theme;
|
||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
const th = app.theme;
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
if (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() });
|
||||
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() });
|
||||
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;
|
||||
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) {
|
||||
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 {
|
||||
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| {
|
||||
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() });
|
||||
|
||||
// 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
|
||||
for (self.options_rows.items, 0..) |row, ri| {
|
||||
const is_cursor = ri == self.options_cursor;
|
||||
for (app.options_rows.items, 0..) |row, ri| {
|
||||
const is_cursor = ri == app.options_cursor;
|
||||
switch (row.kind) {
|
||||
.expiration => {
|
||||
if (row.exp_idx < chains.len) {
|
||||
const chain = chains[row.exp_idx];
|
||||
var db: [10]u8 = undefined;
|
||||
const is_expanded = row.exp_idx < 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 arrow: []const u8 = if (is_expanded) "v " else "> ";
|
||||
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 => {
|
||||
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 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", .{
|
||||
|
|
@ -102,7 +102,7 @@ pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLin
|
|||
}), .style = style });
|
||||
},
|
||||
.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 ";
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
const style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
||||
|
|
|
|||
|
|
@ -10,116 +10,116 @@ const StyledLine = tui.StyledLine;
|
|||
|
||||
// ── Data loading ──────────────────────────────────────────────
|
||||
|
||||
pub fn loadData(self: *App) void {
|
||||
self.perf_loaded = true;
|
||||
self.freeCandles();
|
||||
self.freeDividends();
|
||||
self.trailing_price = null;
|
||||
self.trailing_total = null;
|
||||
self.trailing_me_price = null;
|
||||
self.trailing_me_total = null;
|
||||
self.candle_count = 0;
|
||||
self.candle_first_date = null;
|
||||
self.candle_last_date = null;
|
||||
pub fn loadData(app: *App) void {
|
||||
app.perf_loaded = true;
|
||||
app.freeCandles();
|
||||
app.freeDividends();
|
||||
app.trailing_price = null;
|
||||
app.trailing_total = null;
|
||||
app.trailing_me_price = null;
|
||||
app.trailing_me_total = null;
|
||||
app.candle_count = 0;
|
||||
app.candle_first_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) {
|
||||
zfin.DataError.NoApiKey => self.setStatus("No API key. Set TWELVEDATA_API_KEY"),
|
||||
zfin.DataError.FetchFailed => self.setStatus("Fetch failed (network error or rate limit)"),
|
||||
else => self.setStatus("Error loading data"),
|
||||
zfin.DataError.NoApiKey => app.setStatus("No API key. Set TWELVEDATA_API_KEY"),
|
||||
zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"),
|
||||
else => app.setStatus("Error loading data"),
|
||||
}
|
||||
return;
|
||||
};
|
||||
self.candles = candle_result.data;
|
||||
self.candle_timestamp = candle_result.timestamp;
|
||||
app.candles = candle_result.data;
|
||||
app.candle_timestamp = candle_result.timestamp;
|
||||
|
||||
const c = self.candles.?;
|
||||
const c = app.candles.?;
|
||||
if (c.len == 0) {
|
||||
self.setStatus("No data available for symbol");
|
||||
app.setStatus("No data available for symbol");
|
||||
return;
|
||||
}
|
||||
self.candle_count = c.len;
|
||||
self.candle_first_date = c[0].date;
|
||||
self.candle_last_date = c[c.len - 1].date;
|
||||
app.candle_count = c.len;
|
||||
app.candle_first_date = c[0].date;
|
||||
app.candle_last_date = c[c.len - 1].date;
|
||||
|
||||
const today = fmt.todayDate();
|
||||
self.trailing_price = zfin.performance.trailingReturns(c);
|
||||
self.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today);
|
||||
app.trailing_price = zfin.performance.trailingReturns(c);
|
||||
app.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today);
|
||||
|
||||
if (self.svc.getDividends(self.symbol)) |div_result| {
|
||||
self.dividends = div_result.data;
|
||||
self.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data);
|
||||
self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
|
||||
if (app.svc.getDividends(app.symbol)) |div_result| {
|
||||
app.dividends = div_result.data;
|
||||
app.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data);
|
||||
app.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
|
||||
} 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)
|
||||
if (!self.etf_loaded) {
|
||||
self.etf_loaded = true;
|
||||
if (self.svc.getEtfProfile(self.symbol)) |etf_result| {
|
||||
if (!app.etf_loaded) {
|
||||
app.etf_loaded = true;
|
||||
if (app.svc.getEtfProfile(app.symbol)) |etf_result| {
|
||||
if (etf_result.data.isEtf()) {
|
||||
self.etf_profile = etf_result.data;
|
||||
app.etf_profile = etf_result.data;
|
||||
}
|
||||
} 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 ─────────────────────────────────────────────────
|
||||
|
||||
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
const th = self.theme;
|
||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
const th = app.theme;
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
if (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() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
if (self.candle_last_date) |d| {
|
||||
if (app.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() });
|
||||
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 {
|
||||
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() });
|
||||
|
||||
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() });
|
||||
if (app.trailing_price == null) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (self.candle_count > 0) {
|
||||
if (self.candle_first_date) |first| {
|
||||
if (self.candle_last_date) |last| {
|
||||
if (app.candle_count > 0) {
|
||||
if (app.candle_first_date) |first| {
|
||||
if (app.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),
|
||||
app.candle_count, first.format(&fb), last.format(&lb),
|
||||
}), .style = th.mutedStyle() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (self.candles) |cc| {
|
||||
if (app.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;
|
||||
const has_total = app.trailing_total != null;
|
||||
|
||||
if (self.candle_last_date) |last| {
|
||||
if (app.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);
|
||||
try appendStyledReturnsTable(arena, &lines, app.trailing_price.?, if (has_total) app.trailing_total else null, th);
|
||||
|
||||
{
|
||||
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 = 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 (app.trailing_me_price) |me_price| {
|
||||
try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.trailing_me_total else null, th);
|
||||
}
|
||||
|
||||
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() });
|
||||
}
|
||||
|
||||
if (self.risk_metrics) |tr| {
|
||||
if (app.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() });
|
||||
|
|
|
|||
|
|
@ -44,27 +44,27 @@ const gl_col_start: usize = col_end_market_value;
|
|||
/// On first call, uses prefetched_prices (populated before TUI started).
|
||||
/// On refresh, fetches live via svc.loadPrices. Tab switching skips this
|
||||
/// entirely because the portfolio_loaded guard in loadTabData() short-circuits.
|
||||
pub fn loadPortfolioData(self: *App) void {
|
||||
self.portfolio_loaded = true;
|
||||
self.freePortfolioSummary();
|
||||
pub fn loadPortfolioData(app: *App) void {
|
||||
app.portfolio_loaded = true;
|
||||
app.freePortfolioSummary();
|
||||
|
||||
const pf = self.portfolio orelse return;
|
||||
const pf = app.portfolio orelse return;
|
||||
|
||||
const positions = pf.positions(self.allocator) catch {
|
||||
self.setStatus("Error computing positions");
|
||||
const positions = pf.positions(app.allocator) catch {
|
||||
app.setStatus("Error computing positions");
|
||||
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();
|
||||
|
||||
// Only fetch prices for stock/ETF symbols (skip options, CDs, cash)
|
||||
const syms = pf.stockSymbols(self.allocator) catch {
|
||||
self.setStatus("Error getting symbols");
|
||||
const syms = pf.stockSymbols(app.allocator) catch {
|
||||
app.setStatus("Error getting symbols");
|
||||
return;
|
||||
};
|
||||
defer self.allocator.free(syms);
|
||||
defer app.allocator.free(syms);
|
||||
|
||||
var latest_date: ?zfin.Date = null;
|
||||
var fail_count: usize = 0;
|
||||
|
|
@ -72,7 +72,7 @@ pub fn loadPortfolioData(self: *App) void {
|
|||
var stale_count: usize = 0;
|
||||
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)
|
||||
// Move stock prices into the working map
|
||||
for (syms) |sym| {
|
||||
|
|
@ -82,10 +82,10 @@ pub fn loadPortfolioData(self: *App) void {
|
|||
}
|
||||
|
||||
// Extract watchlist prices
|
||||
if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
|
||||
self.watchlist_prices = std.StringHashMap(f64).init(self.allocator);
|
||||
if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
|
||||
app.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
|
||||
}
|
||||
var wp = &(self.watchlist_prices.?);
|
||||
var wp = &(app.watchlist_prices.?);
|
||||
var pp_iter = pp.iterator();
|
||||
while (pp_iter.next()) |entry| {
|
||||
if (!prices.contains(entry.key_ptr.*)) {
|
||||
|
|
@ -94,17 +94,17 @@ pub fn loadPortfolioData(self: *App) void {
|
|||
}
|
||||
|
||||
pp.deinit();
|
||||
self.prefetched_prices = null;
|
||||
app.prefetched_prices = null;
|
||||
} else {
|
||||
// Live fetch (refresh path) — fetch watchlist first, then stock prices
|
||||
if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
|
||||
self.watchlist_prices = std.StringHashMap(f64).init(self.allocator);
|
||||
if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
|
||||
app.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
|
||||
}
|
||||
var wp = &(self.watchlist_prices.?);
|
||||
if (self.watchlist) |wl| {
|
||||
var wp = &(app.watchlist_prices.?);
|
||||
if (app.watchlist) |wl| {
|
||||
for (wl) |sym| {
|
||||
const result = self.svc.getCandles(sym) catch continue;
|
||||
defer self.allocator.free(result.data);
|
||||
const result = app.svc.getCandles(sym) catch continue;
|
||||
defer app.allocator.free(result.data);
|
||||
if (result.data.len > 0) {
|
||||
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| {
|
||||
if (lot.security_type == .watch) {
|
||||
const sym = lot.priceSymbol();
|
||||
const result = self.svc.getCandles(sym) catch continue;
|
||||
defer self.allocator.free(result.data);
|
||||
const result = app.svc.getCandles(sym) catch continue;
|
||||
defer app.allocator.free(result.data);
|
||||
if (result.data.len > 0) {
|
||||
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 };
|
||||
const load_result = self.svc.loadPrices(syms, &prices, false, tui_progress.callback());
|
||||
var tui_progress = TuiProgress{ .app = app, .failed = &failed_syms };
|
||||
const load_result = app.svc.loadPrices(syms, &prices, false, tui_progress.callback());
|
||||
latest_date = load_result.latest_date;
|
||||
fail_count = load_result.fail_count;
|
||||
fetch_count = load_result.fetched_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
|
||||
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 => {
|
||||
self.setStatus("No cached prices. Run: zfin perf <SYMBOL> first");
|
||||
app.setStatus("No cached prices. Run: zfin perf <SYMBOL> first");
|
||||
return;
|
||||
},
|
||||
error.SummaryFailed => {
|
||||
self.setStatus("Error computing portfolio summary");
|
||||
app.setStatus("Error computing portfolio summary");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
self.setStatus("Error building portfolio data");
|
||||
app.setStatus("Error building portfolio data");
|
||||
return;
|
||||
},
|
||||
};
|
||||
// Transfer ownership: summary stored on App, candle_map freed after snapshots extracted
|
||||
self.portfolio_summary = pf_data.summary;
|
||||
self.historical_snapshots = pf_data.snapshots;
|
||||
app.portfolio_summary = pf_data.summary;
|
||||
app.historical_snapshots = pf_data.snapshots;
|
||||
{
|
||||
// Free candle_map values and map (snapshots are value types, already copied)
|
||||
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();
|
||||
}
|
||||
|
||||
sortPortfolioAllocations(self);
|
||||
rebuildPortfolioRows(self);
|
||||
sortPortfolioAllocations(app);
|
||||
rebuildPortfolioRows(app);
|
||||
|
||||
const summary = pf_data.summary;
|
||||
if (self.symbol.len == 0 and summary.allocations.len > 0) {
|
||||
self.setActiveSymbol(summary.allocations[0].symbol);
|
||||
if (app.symbol.len == 0 and summary.allocations.len > 0) {
|
||||
app.setActiveSymbol(summary.allocations[0].symbol);
|
||||
}
|
||||
|
||||
// Show warning if any securities failed to load
|
||||
|
|
@ -217,31 +217,31 @@ pub fn loadPortfolioData(self: *App) void {
|
|||
}
|
||||
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";
|
||||
self.setStatus(warn_msg);
|
||||
app.setStatus(warn_msg);
|
||||
} else {
|
||||
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 {
|
||||
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";
|
||||
self.setStatus(warn_msg);
|
||||
app.setStatus(warn_msg);
|
||||
} else {
|
||||
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) {
|
||||
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";
|
||||
self.setStatus(info_msg);
|
||||
app.setStatus(info_msg);
|
||||
} 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 {
|
||||
if (self.portfolio_summary) |s| {
|
||||
pub fn sortPortfolioAllocations(app: *App) void {
|
||||
if (app.portfolio_summary) |s| {
|
||||
const SortCtx = struct {
|
||||
field: PortfolioSortField,
|
||||
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 {
|
||||
self.portfolio_rows.clearRetainingCapacity();
|
||||
pub fn rebuildPortfolioRows(app: *App) void {
|
||||
app.portfolio_rows.clearRetainingCapacity();
|
||||
|
||||
if (self.portfolio_summary) |s| {
|
||||
if (app.portfolio_summary) |s| {
|
||||
for (s.allocations, 0..) |a, i| {
|
||||
// Count lots for this symbol
|
||||
var lcount: usize = 0;
|
||||
if (self.portfolio) |pf| {
|
||||
if (app.portfolio) |pf| {
|
||||
for (pf.lots) |lot| {
|
||||
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,
|
||||
.symbol = a.symbol,
|
||||
.pos_idx = i,
|
||||
|
|
@ -286,14 +286,14 @@ pub fn rebuildPortfolioRows(self: *App) void {
|
|||
}) catch continue;
|
||||
|
||||
// Only expand if multi-lot
|
||||
if (lcount > 1 and i < self.expanded.len and self.expanded[i]) {
|
||||
if (self.portfolio) |pf| {
|
||||
if (lcount > 1 and i < app.expanded.len and app.expanded[i]) {
|
||||
if (app.portfolio) |pf| {
|
||||
// Collect matching lots, sort: open first (date desc), then closed (date desc)
|
||||
var matching: std.ArrayList(zfin.Lot) = .empty;
|
||||
defer matching.deinit(self.allocator);
|
||||
defer matching.deinit(app.allocator);
|
||||
for (pf.lots) |lot| {
|
||||
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);
|
||||
|
|
@ -310,7 +310,7 @@ pub fn rebuildPortfolioRows(self: *App) void {
|
|||
if (!has_drip) {
|
||||
// No DRIP lots: show all individually
|
||||
for (matching.items) |lot| {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .lot,
|
||||
.symbol = lot.symbol,
|
||||
.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
|
||||
for (matching.items) |lot| {
|
||||
if (!lot.drip) {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .lot,
|
||||
.symbol = lot.symbol,
|
||||
.pos_idx = i,
|
||||
|
|
@ -334,7 +334,7 @@ pub fn rebuildPortfolioRows(self: *App) void {
|
|||
const drip = fmt.aggregateDripLots(matching.items);
|
||||
|
||||
if (!drip.st.isEmpty()) {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .drip_summary,
|
||||
.symbol = a.symbol,
|
||||
.pos_idx = i,
|
||||
|
|
@ -347,7 +347,7 @@ pub fn rebuildPortfolioRows(self: *App) void {
|
|||
}) catch {};
|
||||
}
|
||||
if (!drip.lt.isEmpty()) {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .drip_summary,
|
||||
.symbol = a.symbol,
|
||||
.pos_idx = i,
|
||||
|
|
@ -367,23 +367,23 @@ pub fn rebuildPortfolioRows(self: *App) void {
|
|||
|
||||
// Add watchlist items from both the separate watchlist file and
|
||||
// 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();
|
||||
|
||||
// Mark all portfolio position symbols as seen
|
||||
if (self.portfolio_summary) |s| {
|
||||
if (app.portfolio_summary) |s| {
|
||||
for (s.allocations) |a| {
|
||||
watch_seen.put(a.symbol, {}) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
// Watch lots from portfolio file
|
||||
if (self.portfolio) |pf| {
|
||||
if (app.portfolio) |pf| {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .watch) {
|
||||
if (watch_seen.contains(lot.priceSymbol())) continue;
|
||||
watch_seen.put(lot.priceSymbol(), {}) catch {};
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .watchlist,
|
||||
.symbol = lot.symbol,
|
||||
}) catch continue;
|
||||
|
|
@ -392,11 +392,11 @@ pub fn rebuildPortfolioRows(self: *App) void {
|
|||
}
|
||||
|
||||
// Separate watchlist file (backward compat)
|
||||
if (self.watchlist) |wl| {
|
||||
if (app.watchlist) |wl| {
|
||||
for (wl) |sym| {
|
||||
if (watch_seen.contains(sym)) continue;
|
||||
watch_seen.put(sym, {}) catch {};
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .watchlist,
|
||||
.symbol = sym,
|
||||
}) catch continue;
|
||||
|
|
@ -404,15 +404,15 @@ pub fn rebuildPortfolioRows(self: *App) void {
|
|||
}
|
||||
|
||||
// Options section
|
||||
if (self.portfolio) |pf| {
|
||||
if (app.portfolio) |pf| {
|
||||
if (pf.hasType(.option)) {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .section_header,
|
||||
.symbol = "Options",
|
||||
}) catch {};
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .option) {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .option_row,
|
||||
.symbol = lot.symbol,
|
||||
.lot = lot,
|
||||
|
|
@ -423,20 +423,20 @@ pub fn rebuildPortfolioRows(self: *App) void {
|
|||
|
||||
// CDs section (sorted by maturity date, earliest first)
|
||||
if (pf.hasType(.cd)) {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .section_header,
|
||||
.symbol = "Certificates of Deposit",
|
||||
}) catch {};
|
||||
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
|
||||
defer cd_lots.deinit(self.allocator);
|
||||
defer cd_lots.deinit(app.allocator);
|
||||
for (pf.lots) |lot| {
|
||||
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);
|
||||
for (cd_lots.items) |lot| {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .cd_row,
|
||||
.symbol = lot.symbol,
|
||||
.lot = lot,
|
||||
|
|
@ -446,20 +446,20 @@ pub fn rebuildPortfolioRows(self: *App) void {
|
|||
|
||||
// Cash section (single total row, expandable to show per-account)
|
||||
if (pf.hasType(.cash)) {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .section_header,
|
||||
.symbol = "Cash",
|
||||
}) catch {};
|
||||
// Total cash row
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .cash_total,
|
||||
.symbol = "CASH",
|
||||
}) catch {};
|
||||
// Per-account cash rows (expanded when cash_total is toggled)
|
||||
if (self.cash_expanded) {
|
||||
if (app.cash_expanded) {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .cash) {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .cash_row,
|
||||
.symbol = lot.account orelse "Unknown",
|
||||
.lot = lot,
|
||||
|
|
@ -471,20 +471,20 @@ pub fn rebuildPortfolioRows(self: *App) void {
|
|||
|
||||
// Illiquid assets section (similar to cash: total row, expandable)
|
||||
if (pf.hasType(.illiquid)) {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .section_header,
|
||||
.symbol = "Illiquid Assets",
|
||||
}) catch {};
|
||||
// Total illiquid row
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .illiquid_total,
|
||||
.symbol = "ILLIQUID",
|
||||
}) catch {};
|
||||
// Per-asset rows (expanded when illiquid_total is toggled)
|
||||
if (self.illiquid_expanded) {
|
||||
if (app.illiquid_expanded) {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .illiquid) {
|
||||
self.portfolio_rows.append(self.allocator, .{
|
||||
app.portfolio_rows.append(app.allocator, .{
|
||||
.kind = .illiquid_row,
|
||||
.symbol = lot.symbol,
|
||||
.lot = lot,
|
||||
|
|
@ -498,18 +498,18 @@ pub fn rebuildPortfolioRows(self: *App) void {
|
|||
|
||||
// ── Rendering ─────────────────────────────────────────────────
|
||||
|
||||
pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
const th = self.theme;
|
||||
pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
const th = app.theme;
|
||||
|
||||
if (self.portfolio == null and self.watchlist == null) {
|
||||
try drawWelcomeScreen(self, arena, buf, width, height);
|
||||
if (app.portfolio == null and app.watchlist == null) {
|
||||
try drawWelcomeScreen(app, arena, buf, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
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 cost_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 });
|
||||
|
||||
// "as of" date indicator
|
||||
if (self.candle_last_date) |d| {
|
||||
if (app.candle_last_date) |d| {
|
||||
var asof_buf: [10]u8 = undefined;
|
||||
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() });
|
||||
}
|
||||
|
||||
// Net Worth line (only if portfolio has illiquid assets)
|
||||
if (self.portfolio) |pf| {
|
||||
if (app.portfolio) |pf| {
|
||||
if (pf.hasType(.illiquid)) {
|
||||
const illiquid_total = pf.totalIlliquid();
|
||||
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
|
||||
if (self.historical_snapshots) |snapshots| {
|
||||
if (app.historical_snapshots) |snapshots| {
|
||||
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: --"
|
||||
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() });
|
||||
}
|
||||
} 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() });
|
||||
} else {
|
||||
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)
|
||||
// Active sort column gets a sort indicator within the column width
|
||||
const sf = self.portfolio_sort_field;
|
||||
const si = self.portfolio_sort_dir.indicator();
|
||||
const sf = app.portfolio_sort_field;
|
||||
const si = app.portfolio_sort_dir.indicator();
|
||||
// Build column labels with indicator embedded in padding
|
||||
// Left-aligned cols: "Name▲ " Right-aligned cols: " ▼Price"
|
||||
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() });
|
||||
|
||||
// Track header line count for mouse click mapping (after all header lines)
|
||||
self.portfolio_header_lines = lines.items.len;
|
||||
self.portfolio_line_count = 0;
|
||||
app.portfolio_header_lines = lines.items.len;
|
||||
app.portfolio_line_count = 0;
|
||||
|
||||
// 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 is_cursor = ri == self.cursor;
|
||||
const is_active_sym = std.mem.eql(u8, row.symbol, self.symbol);
|
||||
const is_cursor = ri == app.cursor;
|
||||
const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol);
|
||||
switch (row.kind) {
|
||||
.position => {
|
||||
if (self.portfolio_summary) |s| {
|
||||
if (app.portfolio_summary) |s| {
|
||||
if (row.pos_idx < s.allocations.len) {
|
||||
const a = s.allocations[row.pos_idx];
|
||||
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 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);
|
||||
|
|
@ -637,7 +637,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
|
|||
var date_col: []const u8 = "";
|
||||
var acct_col: []const u8 = "";
|
||||
if (!is_multi) {
|
||||
if (self.portfolio) |pf| {
|
||||
if (app.portfolio) |pf| {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||
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 {
|
||||
// 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 mixed = false;
|
||||
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_mv_str: []const u8 = "";
|
||||
var lot_positive = true;
|
||||
if (self.portfolio_summary) |s| {
|
||||
if (app.portfolio_summary) |s| {
|
||||
if (row.pos_idx < s.allocations.len) {
|
||||
const price = s.allocations[row.pos_idx].current_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 => {
|
||||
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 "--")
|
||||
else
|
||||
"--";
|
||||
|
|
@ -818,10 +818,10 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt
|
|||
}
|
||||
},
|
||||
.cash_total => {
|
||||
if (self.portfolio) |pf| {
|
||||
if (app.portfolio) |pf| {
|
||||
const total_cash = pf.totalCash();
|
||||
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}", .{
|
||||
arrow3,
|
||||
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 => {
|
||||
if (self.portfolio) |pf| {
|
||||
if (app.portfolio) |pf| {
|
||||
const total_illiquid = pf.totalIlliquid();
|
||||
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}", .{
|
||||
arrow4,
|
||||
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
|
||||
const lines_after = lines.items.len;
|
||||
for (lines_before..lines_after) |li| {
|
||||
const map_idx = li - self.portfolio_header_lines;
|
||||
if (map_idx < self.portfolio_line_to_row.len) {
|
||||
self.portfolio_line_to_row[map_idx] = ri;
|
||||
const map_idx = li - app.portfolio_header_lines;
|
||||
if (map_idx < app.portfolio_line_to_row.len) {
|
||||
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
|
||||
const start = @min(self.scroll_offset, if (lines.items.len > 0) lines.items.len - 1 else 0);
|
||||
try self.drawStyledContent(arena, buf, width, height, lines.items[start..]);
|
||||
const start = @min(app.scroll_offset, if (lines.items.len > 0) lines.items.len - 1 else 0);
|
||||
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 {
|
||||
const th = self.theme;
|
||||
fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
const th = app.theme;
|
||||
const welcome_lines = [_]StyledLine{
|
||||
.{ .text = "", .style = th.contentStyle() },
|
||||
.{ .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::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.
|
||||
/// Uses cached candle data to recompute summary.
|
||||
pub fn reloadPortfolioFile(self: *App) void {
|
||||
pub fn reloadPortfolioFile(app: *App) void {
|
||||
// Re-read the portfolio file
|
||||
if (self.portfolio) |*pf| pf.deinit();
|
||||
self.portfolio = null;
|
||||
if (self.portfolio_path) |path| {
|
||||
const file_data = std.fs.cwd().readFileAlloc(self.allocator, path, 10 * 1024 * 1024) catch {
|
||||
self.setStatus("Error reading portfolio file");
|
||||
if (app.portfolio) |*pf| pf.deinit();
|
||||
app.portfolio = null;
|
||||
if (app.portfolio_path) |path| {
|
||||
const file_data = std.fs.cwd().readFileAlloc(app.allocator, path, 10 * 1024 * 1024) catch {
|
||||
app.setStatus("Error reading portfolio file");
|
||||
return;
|
||||
};
|
||||
defer self.allocator.free(file_data);
|
||||
if (zfin.cache.deserializePortfolio(self.allocator, file_data)) |pf| {
|
||||
self.portfolio = pf;
|
||||
defer app.allocator.free(file_data);
|
||||
if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| {
|
||||
app.portfolio = pf;
|
||||
} else |_| {
|
||||
self.setStatus("Error parsing portfolio file");
|
||||
app.setStatus("Error parsing portfolio file");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
self.setStatus("No portfolio file to reload");
|
||||
app.setStatus("No portfolio file to reload");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload watchlist file too (if separate)
|
||||
tui.freeWatchlist(self.allocator, self.watchlist);
|
||||
self.watchlist = null;
|
||||
if (self.watchlist_path) |path| {
|
||||
self.watchlist = tui.loadWatchlist(self.allocator, path);
|
||||
tui.freeWatchlist(app.allocator, app.watchlist);
|
||||
app.watchlist = null;
|
||||
if (app.watchlist_path) |path| {
|
||||
app.watchlist = tui.loadWatchlist(app.allocator, path);
|
||||
}
|
||||
|
||||
// Recompute summary using cached prices (no network)
|
||||
self.freePortfolioSummary();
|
||||
self.expanded = [_]bool{false} ** 64;
|
||||
self.cash_expanded = false;
|
||||
self.illiquid_expanded = false;
|
||||
self.cursor = 0;
|
||||
self.scroll_offset = 0;
|
||||
self.portfolio_rows.clearRetainingCapacity();
|
||||
app.freePortfolioSummary();
|
||||
app.expanded = [_]bool{false} ** 64;
|
||||
app.cash_expanded = false;
|
||||
app.illiquid_expanded = false;
|
||||
app.cursor = 0;
|
||||
app.scroll_offset = 0;
|
||||
app.portfolio_rows.clearRetainingCapacity();
|
||||
|
||||
const pf = self.portfolio orelse return;
|
||||
const positions = pf.positions(self.allocator) catch {
|
||||
self.setStatus("Error computing positions");
|
||||
const pf = app.portfolio orelse return;
|
||||
const positions = pf.positions(app.allocator) catch {
|
||||
app.setStatus("Error computing positions");
|
||||
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();
|
||||
|
||||
const syms = pf.stockSymbols(self.allocator) catch {
|
||||
self.setStatus("Error getting symbols");
|
||||
const syms = pf.stockSymbols(app.allocator) catch {
|
||||
app.setStatus("Error getting symbols");
|
||||
return;
|
||||
};
|
||||
defer self.allocator.free(syms);
|
||||
defer app.allocator.free(syms);
|
||||
|
||||
var latest_date: ?zfin.Date = null;
|
||||
var missing: usize = 0;
|
||||
for (syms) |sym| {
|
||||
// Cache only — no network
|
||||
const candles_slice = self.svc.getCachedCandles(sym);
|
||||
const candles_slice = app.svc.getCachedCandles(sym);
|
||||
if (candles_slice) |cs| {
|
||||
defer self.allocator.free(cs);
|
||||
defer app.allocator.free(cs);
|
||||
if (cs.len > 0) {
|
||||
prices.put(sym, cs[cs.len - 1].close) catch {};
|
||||
const d = cs[cs.len - 1].date;
|
||||
|
|
@ -993,50 +993,50 @@ pub fn reloadPortfolioFile(self: *App) void {
|
|||
missing += 1;
|
||||
}
|
||||
}
|
||||
self.candle_last_date = latest_date;
|
||||
app.candle_last_date = latest_date;
|
||||
|
||||
// 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 => {
|
||||
self.setStatus("No cached prices available");
|
||||
app.setStatus("No cached prices available");
|
||||
return;
|
||||
},
|
||||
error.SummaryFailed => {
|
||||
self.setStatus("Error computing portfolio summary");
|
||||
app.setStatus("Error computing portfolio summary");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
self.setStatus("Error building portfolio data");
|
||||
app.setStatus("Error building portfolio data");
|
||||
return;
|
||||
},
|
||||
};
|
||||
self.portfolio_summary = pf_data.summary;
|
||||
self.historical_snapshots = pf_data.snapshots;
|
||||
app.portfolio_summary = pf_data.summary;
|
||||
app.historical_snapshots = pf_data.snapshots;
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
sortPortfolioAllocations(self);
|
||||
rebuildPortfolioRows(self);
|
||||
sortPortfolioAllocations(app);
|
||||
rebuildPortfolioRows(app);
|
||||
|
||||
// Invalidate analysis data -- it holds pointers into old portfolio memory
|
||||
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
|
||||
self.analysis_result = null;
|
||||
self.analysis_loaded = false;
|
||||
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
|
||||
app.analysis_result = null;
|
||||
app.analysis_loaded = false;
|
||||
|
||||
// If currently on the analysis tab, eagerly recompute so the user
|
||||
// doesn't see an error message before switching away and back.
|
||||
if (self.active_tab == .analysis) {
|
||||
self.loadAnalysisData();
|
||||
if (app.active_tab == .analysis) {
|
||||
app.loadAnalysisData();
|
||||
}
|
||||
|
||||
if (missing > 0) {
|
||||
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)";
|
||||
self.setStatus(warn_msg);
|
||||
app.setStatus(warn_msg);
|
||||
} else {
|
||||
self.setStatus("Portfolio reloaded from disk");
|
||||
app.setStatus("Portfolio reloaded from disk");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,40 +14,40 @@ const glyph = tui.glyph;
|
|||
|
||||
/// 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 {
|
||||
pub fn drawContent(app: *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) {
|
||||
const use_kitty = switch (app.chart.config.mode) {
|
||||
.braille => false,
|
||||
.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) {
|
||||
drawWithKittyChart(self, ctx, buf, width, height) catch {
|
||||
if (use_kitty and app.candles != null and app.candles.?.len >= 40) {
|
||||
drawWithKittyChart(app, ctx, buf, width, height) catch {
|
||||
// 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 {
|
||||
// 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.
|
||||
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 th = self.theme;
|
||||
const c = self.candles orelse return;
|
||||
const th = app.theme;
|
||||
const c = app.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 });
|
||||
if (app.quote) |q| {
|
||||
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() });
|
||||
if (q.previous_close > 0) {
|
||||
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) {
|
||||
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() });
|
||||
if (c.len >= 2) {
|
||||
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" };
|
||||
for (timeframes) |tf| {
|
||||
const lbl = tf.label();
|
||||
if (tf == self.chart.timeframe) {
|
||||
if (tf == app.chart.timeframe) {
|
||||
tf_buf[tf_pos] = '[';
|
||||
tf_pos += 1;
|
||||
@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)";
|
||||
@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
|
||||
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() });
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
|
|||
|
||||
// Draw the text header
|
||||
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)
|
||||
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;
|
||||
// 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);
|
||||
const capped_w = @min(px_w, app.chart.config.max_width);
|
||||
const capped_h = @min(px_h, app.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;
|
||||
const symbol_changed = app.chart.symbol_len != app.symbol.len or
|
||||
!std.mem.eql(u8, app.chart.symbol[0..app.chart.symbol_len], app.symbol);
|
||||
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
|
||||
if (self.chart.image_id) |old_id| {
|
||||
if (self.vx_app) |va| {
|
||||
if (app.chart.image_id) |old_id| {
|
||||
if (app.vx_app) |va| {
|
||||
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,
|
||||
// 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(
|
||||
self.allocator,
|
||||
app.allocator,
|
||||
c,
|
||||
self.chart.timeframe,
|
||||
app.chart.timeframe,
|
||||
capped_w,
|
||||
capped_h,
|
||||
th,
|
||||
) catch |err| {
|
||||
self.chart.dirty = false;
|
||||
app.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);
|
||||
app.setStatus(msg);
|
||||
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.
|
||||
// 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");
|
||||
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
|
||||
app.chart.dirty = false;
|
||||
app.setStatus("Chart: base64 alloc failed");
|
||||
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 img = va.vx.transmitPreEncodedImage(
|
||||
|
|
@ -183,31 +183,31 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
|
|||
chart_result.height,
|
||||
.rgb,
|
||||
) catch |err| {
|
||||
self.chart.dirty = false;
|
||||
app.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);
|
||||
app.setStatus(msg);
|
||||
return;
|
||||
};
|
||||
|
||||
self.chart.image_id = img.id;
|
||||
self.chart.image_width = @intCast(chart_cols);
|
||||
self.chart.image_height = chart_rows;
|
||||
app.chart.image_id = img.id;
|
||||
app.chart.image_width = @intCast(chart_cols);
|
||||
app.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;
|
||||
const sym_len = @min(app.symbol.len, 16);
|
||||
@memcpy(app.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
|
||||
app.chart.symbol_len = sym_len;
|
||||
app.chart.timeframe_rendered = app.chart.timeframe;
|
||||
app.chart.price_min = chart_result.price_min;
|
||||
app.chart.price_max = chart_result.price_max;
|
||||
app.chart.rsi_latest = chart_result.rsi_latest;
|
||||
app.chart.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
const chart_row_start: usize = header_rows;
|
||||
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,
|
||||
.options = .{
|
||||
.size = .{
|
||||
.rows = self.chart.image_height,
|
||||
.cols = self.chart.image_width,
|
||||
.rows = app.chart.image_height,
|
||||
.cols = app.chart.image_width,
|
||||
},
|
||||
.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) ───────────
|
||||
// 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 img_rows = app.chart.image_height;
|
||||
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.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) {
|
||||
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%)
|
||||
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 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: usize = @intFromFloat(@round(row_f));
|
||||
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
|
||||
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) {
|
||||
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 quote_data = app.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);
|
||||
try buildDetailColumns(app, 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);
|
||||
try app.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;
|
||||
fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
const th = app.theme;
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
if (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() });
|
||||
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| {
|
||||
if (app.quote != null and app.quote_timestamp > 0) {
|
||||
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})", .{ app.symbol, ago_str }), .style = th.headerStyle() });
|
||||
} else if (app.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() });
|
||||
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 {
|
||||
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() });
|
||||
|
||||
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)
|
||||
const quote_data = self.quote;
|
||||
const quote_data = app.quote;
|
||||
|
||||
const c = self.candles orelse {
|
||||
const c = app.candles orelse {
|
||||
if (quote_data) |q| {
|
||||
// No candle data but have a quote - show it
|
||||
var qclose_buf: [24]u8 = undefined;
|
||||
|
|
@ -353,7 +353,7 @@ fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|||
}
|
||||
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);
|
||||
};
|
||||
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 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
|
||||
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 {
|
||||
try self.texts.append(arena, text);
|
||||
try self.styles.append(arena, style);
|
||||
fn add(app: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void {
|
||||
try app.texts.append(arena, text);
|
||||
try app.styles.append(arena, style);
|
||||
}
|
||||
|
||||
fn len(self: *const Column) usize {
|
||||
return self.texts.items.len;
|
||||
fn len(app: *const Column) usize {
|
||||
return app.texts.items.len;
|
||||
}
|
||||
};
|
||||
|
||||
fn buildDetailColumns(
|
||||
self: *App,
|
||||
app: *App,
|
||||
arena: std.mem.Allocator,
|
||||
lines: *std.ArrayList(StyledLine),
|
||||
latest: zfin.Candle,
|
||||
|
|
@ -423,7 +423,7 @@ fn buildDetailColumns(
|
|||
price: f64,
|
||||
prev_close: f64,
|
||||
) !void {
|
||||
const th = self.theme;
|
||||
const th = app.theme;
|
||||
var date_buf: [10]u8 = undefined;
|
||||
var close_buf: [24]u8 = undefined;
|
||||
var vol_buf: [32]u8 = undefined;
|
||||
|
|
@ -453,7 +453,7 @@ fn buildDetailColumns(
|
|||
var col4 = Column.init(); // Top holdings
|
||||
col4.width = 30;
|
||||
|
||||
if (self.etf_profile) |profile| {
|
||||
if (app.etf_profile) |profile| {
|
||||
// Col 2: ETF key stats
|
||||
try col2.add(arena, "ETF Profile", th.headerStyle());
|
||||
if (profile.expense_ratio) |er| {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue