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 => {
|
.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]);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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() });
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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| {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue