ai: refactor tui/fix options panic
This commit is contained in:
parent
7a907fcc8d
commit
71f328b329
9 changed files with 2362 additions and 2213 deletions
|
|
@ -48,7 +48,7 @@ pub const Client = struct {
|
||||||
return self.request(.POST, url, body, extra_headers);
|
return self.request(.POST, url, body, extra_headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
|
pub fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
|
||||||
var attempt: u8 = 0;
|
var attempt: u8 = 0;
|
||||||
while (true) : (attempt += 1) {
|
while (true) : (attempt += 1) {
|
||||||
const response = self.doRequest(method, url, body, extra_headers) catch {
|
const response = self.doRequest(method, url, body, extra_headers) catch {
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,9 @@ pub const Cboe = struct {
|
||||||
const url = try buildCboeUrl(allocator, symbol);
|
const url = try buildCboeUrl(allocator, symbol);
|
||||||
defer allocator.free(url);
|
defer allocator.free(url);
|
||||||
|
|
||||||
var response = try self.client.get(url);
|
// Request with Accept-Encoding: identity to avoid Zig 0.15 stdlib deflate panic
|
||||||
|
// on malformed compressed responses from CBOE's CDN.
|
||||||
|
var response = try self.client.request(.GET, url, null, &.{.{ .name = "Accept-Encoding", .value = "identity" }});
|
||||||
defer response.deinit();
|
defer response.deinit();
|
||||||
|
|
||||||
return parseResponse(allocator, response.body, symbol);
|
return parseResponse(allocator, response.body, symbol);
|
||||||
|
|
|
||||||
2263
src/tui.zig
2263
src/tui.zig
File diff suppressed because it is too large
Load diff
258
src/tui/analysis_tab.zig
Normal file
258
src/tui/analysis_tab.zig
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const vaxis = @import("vaxis");
|
||||||
|
const zfin = @import("../root.zig");
|
||||||
|
const fmt = @import("../format.zig");
|
||||||
|
const theme_mod = @import("theme.zig");
|
||||||
|
const tui = @import("../tui.zig");
|
||||||
|
const App = tui.App;
|
||||||
|
const StyledLine = tui.StyledLine;
|
||||||
|
|
||||||
|
// ── Data loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn loadData(self: *App) void {
|
||||||
|
self.analysis_loaded = true;
|
||||||
|
|
||||||
|
// Ensure portfolio is loaded first
|
||||||
|
if (!self.portfolio_loaded) self.loadPortfolioData();
|
||||||
|
const pf = self.portfolio orelse return;
|
||||||
|
const summary = self.portfolio_summary orelse return;
|
||||||
|
|
||||||
|
// Load classification metadata file
|
||||||
|
if (self.classification_map == null) {
|
||||||
|
// Look for metadata.srf next to the portfolio file
|
||||||
|
if (self.portfolio_path) |ppath| {
|
||||||
|
// Derive metadata path: same directory as portfolio, named "metadata.srf"
|
||||||
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
|
||||||
|
const meta_path = std.fmt.allocPrint(self.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
|
||||||
|
defer self.allocator.free(meta_path);
|
||||||
|
|
||||||
|
const file_data = std.fs.cwd().readFileAlloc(self.allocator, meta_path, 1024 * 1024) catch {
|
||||||
|
self.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer self.allocator.free(file_data);
|
||||||
|
|
||||||
|
self.classification_map = zfin.classification.parseClassificationFile(self.allocator, file_data) catch {
|
||||||
|
self.setStatus("Error parsing metadata.srf");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load account tax type metadata file (optional)
|
||||||
|
if (self.account_map == null) {
|
||||||
|
if (self.portfolio_path) |ppath| {
|
||||||
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
|
||||||
|
const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch {
|
||||||
|
loadDataFinish(self, pf, summary);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer self.allocator.free(acct_path);
|
||||||
|
|
||||||
|
if (std.fs.cwd().readFileAlloc(self.allocator, acct_path, 1024 * 1024)) |acct_data| {
|
||||||
|
defer self.allocator.free(acct_data);
|
||||||
|
self.account_map = zfin.analysis.parseAccountsFile(self.allocator, acct_data) catch null;
|
||||||
|
} else |_| {
|
||||||
|
// accounts.srf is optional -- analysis works without it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDataFinish(self, pf, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn loadDataFinish(self: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
|
||||||
|
const cm = self.classification_map orelse {
|
||||||
|
self.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Free previous result
|
||||||
|
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
|
||||||
|
|
||||||
|
self.analysis_result = zfin.analysis.analyzePortfolio(
|
||||||
|
self.allocator,
|
||||||
|
summary.allocations,
|
||||||
|
cm,
|
||||||
|
pf,
|
||||||
|
summary.total_value,
|
||||||
|
self.account_map,
|
||||||
|
) catch {
|
||||||
|
self.setStatus("Error computing analysis");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
|
return renderAnalysisLines(arena, self.theme, self.analysis_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render analysis tab content. Pure function — no App dependency.
|
||||||
|
pub fn renderAnalysisLines(
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
th: theme_mod.Theme,
|
||||||
|
analysis_result: ?zfin.analysis.AnalysisResult,
|
||||||
|
) ![]const StyledLine {
|
||||||
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
|
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
try lines.append(arena, .{ .text = " Portfolio Analysis", .style = th.headerStyle() });
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
const result = analysis_result orelse {
|
||||||
|
try lines.append(arena, .{ .text = " No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() });
|
||||||
|
try lines.append(arena, .{ .text = " Run: zfin enrich <portfolio.srf> > metadata.srf", .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bar_width: usize = 30;
|
||||||
|
const label_width: usize = 24;
|
||||||
|
|
||||||
|
const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{
|
||||||
|
.{ .items = result.asset_class, .title = " Asset Class" },
|
||||||
|
.{ .items = result.sector, .title = " Sector (Equities)" },
|
||||||
|
.{ .items = result.geo, .title = " Geographic" },
|
||||||
|
.{ .items = result.account, .title = " By Account" },
|
||||||
|
.{ .items = result.tax_type, .title = " By Tax Type" },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (sections, 0..) |sec, si| {
|
||||||
|
if (si > 0 and sec.items.len == 0) continue;
|
||||||
|
if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
try lines.append(arena, .{ .text = sec.title, .style = th.headerStyle() });
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
for (sec.items) |item| {
|
||||||
|
const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
|
||||||
|
try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.unclassified.len > 0) {
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
try lines.append(arena, .{ .text = " Unclassified (not in metadata.srf)", .style = th.warningStyle() });
|
||||||
|
for (result.unclassified) |sym| {
|
||||||
|
const text = try std.fmt.allocPrint(arena, " {s}", .{sym});
|
||||||
|
try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 {
|
||||||
|
var val_buf: [24]u8 = undefined;
|
||||||
|
const pct = item.weight * 100.0;
|
||||||
|
const bar = try buildBlockBar(arena, item.weight, bar_width);
|
||||||
|
// Build label padded to label_width
|
||||||
|
const lbl = item.label;
|
||||||
|
const lbl_len = @min(lbl.len, label_width);
|
||||||
|
const padded_label = try arena.alloc(u8, label_width);
|
||||||
|
@memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]);
|
||||||
|
if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' ');
|
||||||
|
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {s}", .{
|
||||||
|
padded_label, bar, pct, fmt.fmtMoneyAbs(&val_buf, item.value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a bar using Unicode block elements for sub-character precision.
|
||||||
|
/// Wraps fmt.buildBlockBar into arena-allocated memory.
|
||||||
|
pub fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
const result = fmt.buildBlockBar(&buf, weight, total_chars);
|
||||||
|
return arena.dupe(u8, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
test "buildBlockBar empty" {
|
||||||
|
const bar = try buildBlockBar(testing.allocator, 0, 10);
|
||||||
|
defer testing.allocator.free(bar);
|
||||||
|
// All spaces
|
||||||
|
try testing.expectEqual(@as(usize, 10), bar.len);
|
||||||
|
try testing.expectEqualStrings(" ", bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildBlockBar full" {
|
||||||
|
const bar = try buildBlockBar(testing.allocator, 1.0, 5);
|
||||||
|
defer testing.allocator.free(bar);
|
||||||
|
// 5 full blocks, each 3 bytes UTF-8 (█ = E2 96 88)
|
||||||
|
try testing.expectEqual(@as(usize, 15), bar.len);
|
||||||
|
// Verify first block is █
|
||||||
|
try testing.expectEqualStrings("\xe2\x96\x88", bar[0..3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildBlockBar partial" {
|
||||||
|
const bar = try buildBlockBar(testing.allocator, 0.5, 10);
|
||||||
|
defer testing.allocator.free(bar);
|
||||||
|
// 50% of 10 chars = 5 full blocks (no partial)
|
||||||
|
// 5 full blocks (15 bytes) + 5 spaces = 20 bytes
|
||||||
|
try testing.expectEqual(@as(usize, 20), bar.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "fmtBreakdownLine formats correctly" {
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
|
||||||
|
const item = zfin.analysis.BreakdownItem{
|
||||||
|
.label = "US Stock",
|
||||||
|
.weight = 0.65,
|
||||||
|
.value = 130000,
|
||||||
|
};
|
||||||
|
const line = try fmtBreakdownLine(arena, item, 10, 12);
|
||||||
|
// Should contain the label, percentage, and dollar amount
|
||||||
|
try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null);
|
||||||
|
try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null);
|
||||||
|
try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "renderAnalysisLines with data" {
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
const th = theme_mod.default_theme;
|
||||||
|
|
||||||
|
var asset_class = [_]zfin.analysis.BreakdownItem{
|
||||||
|
.{ .label = "US Stock", .weight = 0.60, .value = 120000 },
|
||||||
|
.{ .label = "Int'l Stock", .weight = 0.40, .value = 80000 },
|
||||||
|
};
|
||||||
|
const result = zfin.analysis.AnalysisResult{
|
||||||
|
.asset_class = &asset_class,
|
||||||
|
.sector = &.{},
|
||||||
|
.geo = &.{},
|
||||||
|
.account = &.{},
|
||||||
|
.tax_type = &.{},
|
||||||
|
.unclassified = &.{},
|
||||||
|
.total_value = 200000,
|
||||||
|
};
|
||||||
|
const lines = try renderAnalysisLines(arena, th, result);
|
||||||
|
// Should have header section + asset class items
|
||||||
|
try testing.expect(lines.len >= 5);
|
||||||
|
// Find "Portfolio Analysis" header
|
||||||
|
var found_header = false;
|
||||||
|
for (lines) |l| {
|
||||||
|
if (std.mem.indexOf(u8, l.text, "Portfolio Analysis") != null) found_header = true;
|
||||||
|
}
|
||||||
|
try testing.expect(found_header);
|
||||||
|
// Find asset class data
|
||||||
|
var found_us = false;
|
||||||
|
for (lines) |l| {
|
||||||
|
if (std.mem.indexOf(u8, l.text, "US Stock") != null) found_us = true;
|
||||||
|
}
|
||||||
|
try testing.expect(found_us);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "renderAnalysisLines no data" {
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
const th = theme_mod.default_theme;
|
||||||
|
|
||||||
|
const lines = try renderAnalysisLines(arena, th, null);
|
||||||
|
try testing.expectEqual(@as(usize, 5), lines.len);
|
||||||
|
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
|
||||||
|
}
|
||||||
161
src/tui/earnings_tab.zig
Normal file
161
src/tui/earnings_tab.zig
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const vaxis = @import("vaxis");
|
||||||
|
const zfin = @import("../root.zig");
|
||||||
|
const fmt = @import("../format.zig");
|
||||||
|
const theme_mod = @import("theme.zig");
|
||||||
|
const tui = @import("../tui.zig");
|
||||||
|
const App = tui.App;
|
||||||
|
const StyledLine = tui.StyledLine;
|
||||||
|
|
||||||
|
// ── Data loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn loadData(self: *App) void {
|
||||||
|
self.earnings_loaded = true;
|
||||||
|
self.freeEarnings();
|
||||||
|
|
||||||
|
const result = self.svc.getEarnings(self.symbol) catch |err| {
|
||||||
|
switch (err) {
|
||||||
|
zfin.DataError.NoApiKey => self.setStatus("No API key. Set FINNHUB_API_KEY"),
|
||||||
|
zfin.DataError.FetchFailed => {
|
||||||
|
self.earnings_disabled = true;
|
||||||
|
self.setStatus("No earnings data (ETF/index?)");
|
||||||
|
},
|
||||||
|
else => self.setStatus("Error loading earnings"),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.earnings_data = result.data;
|
||||||
|
self.earnings_timestamp = result.timestamp;
|
||||||
|
|
||||||
|
if (result.data.len == 0) {
|
||||||
|
self.earnings_disabled = true;
|
||||||
|
self.setStatus("No earnings data available (ETF/index?)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
|
return renderEarningsLines(arena, self.theme, self.symbol, self.earnings_disabled, self.earnings_data, self.earnings_timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render earnings tab content. Pure function — no App dependency.
|
||||||
|
pub fn renderEarningsLines(
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
th: theme_mod.Theme,
|
||||||
|
symbol: []const u8,
|
||||||
|
earnings_disabled: bool,
|
||||||
|
earnings_data: ?[]const zfin.EarningsEvent,
|
||||||
|
earnings_timestamp: i64,
|
||||||
|
) ![]const StyledLine {
|
||||||
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
|
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
if (symbol.len == 0) {
|
||||||
|
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
if (earnings_disabled) {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{symbol}), .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
var earn_ago_buf: [16]u8 = undefined;
|
||||||
|
const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, earnings_timestamp);
|
||||||
|
if (earn_ago.len > 0) {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ symbol, earn_ago }), .style = th.headerStyle() });
|
||||||
|
} else {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s}", .{symbol}), .style = th.headerStyle() });
|
||||||
|
}
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
const ev = earnings_data orelse {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{symbol}), .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
};
|
||||||
|
if (ev.len == 0) {
|
||||||
|
try lines.append(arena, .{ .text = " No earnings events found.", .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{
|
||||||
|
"Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %",
|
||||||
|
}), .style = th.mutedStyle() });
|
||||||
|
|
||||||
|
for (ev) |e| {
|
||||||
|
var row_buf: [128]u8 = undefined;
|
||||||
|
const row = fmt.fmtEarningsRow(&row_buf, e);
|
||||||
|
|
||||||
|
const text = try std.fmt.allocPrint(arena, " {s}", .{row.text});
|
||||||
|
const row_style = if (row.is_future) th.mutedStyle() else if (row.is_positive) th.positiveStyle() else th.negativeStyle();
|
||||||
|
|
||||||
|
try lines.append(arena, .{ .text = text, .style = row_style });
|
||||||
|
}
|
||||||
|
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {d} earnings event(s)", .{ev.len}), .style = th.mutedStyle() });
|
||||||
|
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
test "renderEarningsLines with earnings data" {
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
const th = theme_mod.default_theme;
|
||||||
|
|
||||||
|
const events = [_]zfin.EarningsEvent{.{
|
||||||
|
.symbol = "AAPL",
|
||||||
|
.date = try zfin.Date.parse("2025-01-15"),
|
||||||
|
.quarter = 4,
|
||||||
|
.estimate = 1.50,
|
||||||
|
.actual = 1.65,
|
||||||
|
}};
|
||||||
|
const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0);
|
||||||
|
// blank + header + blank + col_header + data_row + blank + count = 7
|
||||||
|
try testing.expectEqual(@as(usize, 7), lines.len);
|
||||||
|
try testing.expect(std.mem.indexOf(u8, lines[1].text, "AAPL") != null);
|
||||||
|
try testing.expect(std.mem.indexOf(u8, lines[3].text, "EPS Est") != null);
|
||||||
|
// Data row should contain the date
|
||||||
|
try testing.expect(std.mem.indexOf(u8, lines[4].text, "2025-01-15") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "renderEarningsLines no symbol" {
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
const th = theme_mod.default_theme;
|
||||||
|
|
||||||
|
const lines = try renderEarningsLines(arena, th, "", false, null, 0);
|
||||||
|
try testing.expectEqual(@as(usize, 2), lines.len);
|
||||||
|
try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "renderEarningsLines disabled" {
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
const th = theme_mod.default_theme;
|
||||||
|
|
||||||
|
const lines = try renderEarningsLines(arena, th, "VTI", true, null, 0);
|
||||||
|
try testing.expectEqual(@as(usize, 2), lines.len);
|
||||||
|
try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "renderEarningsLines no data" {
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
const th = theme_mod.default_theme;
|
||||||
|
|
||||||
|
const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0);
|
||||||
|
try testing.expectEqual(@as(usize, 4), lines.len);
|
||||||
|
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No data") != null);
|
||||||
|
}
|
||||||
116
src/tui/options_tab.zig
Normal file
116
src/tui/options_tab.zig
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const vaxis = @import("vaxis");
|
||||||
|
const zfin = @import("../root.zig");
|
||||||
|
const fmt = @import("../format.zig");
|
||||||
|
const theme_mod = @import("theme.zig");
|
||||||
|
const tui = @import("../tui.zig");
|
||||||
|
|
||||||
|
const App = tui.App;
|
||||||
|
const StyledLine = tui.StyledLine;
|
||||||
|
|
||||||
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
|
const th = self.theme;
|
||||||
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
|
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
if (self.symbol.len == 0) {
|
||||||
|
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chains = self.options_data orelse {
|
||||||
|
try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chains.len == 0) {
|
||||||
|
try lines.append(arena, .{ .text = " No options data found.", .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
var opt_ago_buf: [16]u8 = undefined;
|
||||||
|
const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, self.options_timestamp);
|
||||||
|
if (opt_ago.len > 0) {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ self.symbol, opt_ago }), .style = th.headerStyle() });
|
||||||
|
} else {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{self.symbol}), .style = th.headerStyle() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chains[0].underlying_price) |price| {
|
||||||
|
var price_buf: [24]u8 = undefined;
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
// Track header line count for mouse click mapping (after all non-data lines)
|
||||||
|
self.options_header_lines = lines.items.len;
|
||||||
|
|
||||||
|
// Flat list of options rows with inline expand/collapse
|
||||||
|
for (self.options_rows.items, 0..) |row, ri| {
|
||||||
|
const is_cursor = ri == self.options_cursor;
|
||||||
|
switch (row.kind) {
|
||||||
|
.expiration => {
|
||||||
|
if (row.exp_idx < chains.len) {
|
||||||
|
const chain = chains[row.exp_idx];
|
||||||
|
var db: [10]u8 = undefined;
|
||||||
|
const is_expanded = row.exp_idx < self.options_expanded.len and self.options_expanded[row.exp_idx];
|
||||||
|
const is_monthly = fmt.isMonthlyExpiration(chain.expiration);
|
||||||
|
const arrow: []const u8 = if (is_expanded) "v " else "> ";
|
||||||
|
const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{
|
||||||
|
arrow,
|
||||||
|
chain.expiration.format(&db),
|
||||||
|
chain.calls.len,
|
||||||
|
chain.puts.len,
|
||||||
|
});
|
||||||
|
const style = if (is_cursor) th.selectStyle() else if (is_monthly) th.contentStyle() else th.mutedStyle();
|
||||||
|
try lines.append(arena, .{ .text = text, .style = style });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.calls_header => {
|
||||||
|
const calls_collapsed = row.exp_idx < self.options_calls_collapsed.len and self.options_calls_collapsed[row.exp_idx];
|
||||||
|
const arrow: []const u8 = if (calls_collapsed) " > " else " v ";
|
||||||
|
const style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Calls", .{
|
||||||
|
arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
|
||||||
|
}), .style = style });
|
||||||
|
},
|
||||||
|
.puts_header => {
|
||||||
|
const puts_collapsed = row.exp_idx < self.options_puts_collapsed.len and self.options_puts_collapsed[row.exp_idx];
|
||||||
|
const arrow: []const u8 = if (puts_collapsed) " > " else " v ";
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
const style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Puts", .{
|
||||||
|
arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
|
||||||
|
}), .style = style });
|
||||||
|
},
|
||||||
|
.call => {
|
||||||
|
if (row.contract) |cc| {
|
||||||
|
const atm_price = chains[0].underlying_price orelse 0;
|
||||||
|
const itm = cc.strike <= atm_price;
|
||||||
|
const prefix: []const u8 = if (itm) " |" else " ";
|
||||||
|
var contract_buf: [128]u8 = undefined;
|
||||||
|
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, cc));
|
||||||
|
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||||
|
try lines.append(arena, .{ .text = text, .style = style });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.put => {
|
||||||
|
if (row.contract) |p| {
|
||||||
|
const atm_price = chains[0].underlying_price orelse 0;
|
||||||
|
const itm = p.strike >= atm_price;
|
||||||
|
const prefix: []const u8 = if (itm) " |" else " ";
|
||||||
|
var contract_buf: [128]u8 = undefined;
|
||||||
|
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, p));
|
||||||
|
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||||
|
try lines.append(arena, .{ .text = text, .style = style });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
151
src/tui/perf_tab.zig
Normal file
151
src/tui/perf_tab.zig
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const vaxis = @import("vaxis");
|
||||||
|
const zfin = @import("../root.zig");
|
||||||
|
const fmt = @import("../format.zig");
|
||||||
|
const theme_mod = @import("theme.zig");
|
||||||
|
const tui = @import("../tui.zig");
|
||||||
|
|
||||||
|
const App = tui.App;
|
||||||
|
const StyledLine = tui.StyledLine;
|
||||||
|
|
||||||
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
|
const th = self.theme;
|
||||||
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
|
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
if (self.symbol.len == 0) {
|
||||||
|
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.candle_last_date) |d| {
|
||||||
|
var pdate_buf: [10]u8 = undefined;
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {s})", .{ self.symbol, d.format(&pdate_buf) }), .style = th.headerStyle() });
|
||||||
|
} else {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{self.symbol}), .style = th.headerStyle() });
|
||||||
|
}
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
if (self.candles == null and !self.perf_loaded) self.loadPerfData();
|
||||||
|
|
||||||
|
if (self.trailing_price == null) {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{self.symbol}), .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.candle_count > 0) {
|
||||||
|
if (self.candle_first_date) |first| {
|
||||||
|
if (self.candle_last_date) |last| {
|
||||||
|
var fb: [10]u8 = undefined;
|
||||||
|
var lb: [10]u8 = undefined;
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({s} to {s})", .{
|
||||||
|
self.candle_count, first.format(&fb), last.format(&lb),
|
||||||
|
}), .style = th.mutedStyle() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.candles) |cc| {
|
||||||
|
if (cc.len > 0) {
|
||||||
|
var close_buf: [24]u8 = undefined;
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoneyAbs(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const has_total = self.trailing_total != null;
|
||||||
|
|
||||||
|
if (self.candle_last_date) |last| {
|
||||||
|
var db: [10]u8 = undefined;
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {s}:", .{last.format(&db)}), .style = th.headerStyle() });
|
||||||
|
}
|
||||||
|
try appendStyledReturnsTable(arena, &lines, self.trailing_price.?, if (has_total) self.trailing_total else null, th);
|
||||||
|
|
||||||
|
{
|
||||||
|
const today = fmt.todayDate();
|
||||||
|
const month_end = today.lastDayOfPriorMonth();
|
||||||
|
var db: [10]u8 = undefined;
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({s}):", .{month_end.format(&db)}), .style = th.headerStyle() });
|
||||||
|
}
|
||||||
|
if (self.trailing_me_price) |me_price| {
|
||||||
|
try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) self.trailing_me_total else null, th);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!has_total) {
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.risk_metrics) |tr| {
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() });
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ "", "Volatility", "Sharpe", "Max DD" }), .style = th.mutedStyle() });
|
||||||
|
|
||||||
|
const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year };
|
||||||
|
const risk_labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" };
|
||||||
|
|
||||||
|
for (0..4) |i| {
|
||||||
|
if (risk_arr[i]) |rm| {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {d:>13.1}% {d:>14.2} {d:>13.1}%", .{
|
||||||
|
risk_labels[i], rm.volatility * 100.0, rm.sharpe, rm.max_drawdown * 100.0,
|
||||||
|
}), .style = th.contentStyle() });
|
||||||
|
} else {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{
|
||||||
|
risk_labels[i], "—", "—", "—",
|
||||||
|
}), .style = th.mutedStyle() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn appendStyledReturnsTable(
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
lines: *std.ArrayList(StyledLine),
|
||||||
|
price: zfin.performance.TrailingReturns,
|
||||||
|
total: ?zfin.performance.TrailingReturns,
|
||||||
|
th: theme_mod.Theme,
|
||||||
|
) !void {
|
||||||
|
const has_total = total != null;
|
||||||
|
if (has_total) {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}", .{ "", "Price Only", "Total Return" }), .style = th.mutedStyle() });
|
||||||
|
} else {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{ "", "Price Only" }), .style = th.mutedStyle() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const price_arr = [4]?zfin.performance.PerformanceResult{ price.one_year, price.three_year, price.five_year, price.ten_year };
|
||||||
|
const total_arr_vals: [4]?zfin.performance.PerformanceResult = if (total) |t|
|
||||||
|
.{ t.one_year, t.three_year, t.five_year, t.ten_year }
|
||||||
|
else
|
||||||
|
.{ null, null, null, null };
|
||||||
|
const labels = [4][]const u8{ "1-Year Return:", "3-Year Return:", "5-Year Return:", "10-Year Return:" };
|
||||||
|
const annualize = [4]bool{ false, true, true, true };
|
||||||
|
|
||||||
|
for (0..4) |i| {
|
||||||
|
var price_buf: [32]u8 = undefined;
|
||||||
|
var total_buf: [32]u8 = undefined;
|
||||||
|
const row = fmt.fmtReturnsRow(
|
||||||
|
&price_buf,
|
||||||
|
&total_buf,
|
||||||
|
price_arr[i],
|
||||||
|
if (has_total) total_arr_vals[i] else null,
|
||||||
|
annualize[i],
|
||||||
|
);
|
||||||
|
|
||||||
|
const row_style = if (price_arr[i] != null)
|
||||||
|
(if (row.price_positive) th.positiveStyle() else th.negativeStyle())
|
||||||
|
else
|
||||||
|
th.mutedStyle();
|
||||||
|
|
||||||
|
if (has_total) {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}{s}", .{ labels[i], row.price_str, row.total_str orelse "N/A", row.suffix }), .style = row_style });
|
||||||
|
} else {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}{s}", .{ labels[i], row.price_str, row.suffix }), .style = row_style });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1053
src/tui/portfolio_tab.zig
Normal file
1053
src/tui/portfolio_tab.zig
Normal file
File diff suppressed because it is too large
Load diff
567
src/tui/quote_tab.zig
Normal file
567
src/tui/quote_tab.zig
Normal file
|
|
@ -0,0 +1,567 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const vaxis = @import("vaxis");
|
||||||
|
const zfin = @import("../root.zig");
|
||||||
|
const fmt = @import("../format.zig");
|
||||||
|
const theme_mod = @import("theme.zig");
|
||||||
|
const chart_mod = @import("chart.zig");
|
||||||
|
const tui = @import("../tui.zig");
|
||||||
|
|
||||||
|
const App = tui.App;
|
||||||
|
const StyledLine = tui.StyledLine;
|
||||||
|
const glyph = tui.glyph;
|
||||||
|
|
||||||
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
|
||||||
|
/// falling back to braille sparkline otherwise.
|
||||||
|
pub fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||||
|
const arena = ctx.arena;
|
||||||
|
|
||||||
|
// Determine whether to use Kitty graphics
|
||||||
|
const use_kitty = switch (self.chart_config.mode) {
|
||||||
|
.braille => false,
|
||||||
|
.kitty => true,
|
||||||
|
.auto => if (self.vx_app) |va| va.vx.caps.kitty_graphics else false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (use_kitty and self.candles != null and self.candles.?.len >= 40) {
|
||||||
|
drawWithKittyChart(self, ctx, buf, width, height) catch {
|
||||||
|
// On any failure, fall back to braille
|
||||||
|
try self.drawStyledContent(arena, buf, width, height, try buildStyledLines(self, arena));
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Fallback to styled lines with braille chart
|
||||||
|
try self.drawStyledContent(arena, buf, width, height, try buildStyledLines(self, arena));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw quote tab using Kitty graphics protocol for the chart.
|
||||||
|
fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||||
|
const arena = ctx.arena;
|
||||||
|
const th = self.theme;
|
||||||
|
const c = self.candles orelse return;
|
||||||
|
|
||||||
|
// Build text header (symbol, price, change) — first few lines
|
||||||
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
// Symbol + price header
|
||||||
|
if (self.quote) |q| {
|
||||||
|
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ self.symbol, q.close });
|
||||||
|
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||||
|
if (q.previous_close > 0) {
|
||||||
|
const change = q.close - q.previous_close;
|
||||||
|
const pct = (change / q.previous_close) * 100.0;
|
||||||
|
var chg_buf: [64]u8 = undefined;
|
||||||
|
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
||||||
|
}
|
||||||
|
} else if (c.len > 0) {
|
||||||
|
const last = c[c.len - 1];
|
||||||
|
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ self.symbol, last.close });
|
||||||
|
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||||
|
if (c.len >= 2) {
|
||||||
|
const prev_close = c[c.len - 2].close;
|
||||||
|
const change = last.close - prev_close;
|
||||||
|
const pct = (change / prev_close) * 100.0;
|
||||||
|
var chg_buf: [64]u8 = undefined;
|
||||||
|
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeframe selector line
|
||||||
|
{
|
||||||
|
var tf_buf: [80]u8 = undefined;
|
||||||
|
var tf_pos: usize = 0;
|
||||||
|
const prefix = " Chart: ";
|
||||||
|
@memcpy(tf_buf[tf_pos..][0..prefix.len], prefix);
|
||||||
|
tf_pos += prefix.len;
|
||||||
|
const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
||||||
|
for (timeframes) |tf| {
|
||||||
|
const lbl = tf.label();
|
||||||
|
if (tf == self.chart_timeframe) {
|
||||||
|
tf_buf[tf_pos] = '[';
|
||||||
|
tf_pos += 1;
|
||||||
|
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
|
||||||
|
tf_pos += lbl.len;
|
||||||
|
tf_buf[tf_pos] = ']';
|
||||||
|
tf_pos += 1;
|
||||||
|
} else {
|
||||||
|
tf_buf[tf_pos] = ' ';
|
||||||
|
tf_pos += 1;
|
||||||
|
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
|
||||||
|
tf_pos += lbl.len;
|
||||||
|
tf_buf[tf_pos] = ' ';
|
||||||
|
tf_pos += 1;
|
||||||
|
}
|
||||||
|
tf_buf[tf_pos] = ' ';
|
||||||
|
tf_pos += 1;
|
||||||
|
}
|
||||||
|
const hint = " ([ ] to change)";
|
||||||
|
@memcpy(tf_buf[tf_pos..][0..hint.len], hint);
|
||||||
|
tf_pos += hint.len;
|
||||||
|
self.chart_timeframe_row = lines.items.len; // track which row the timeframe line is on
|
||||||
|
try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
// Draw the text header
|
||||||
|
const header_lines = try lines.toOwnedSlice(arena);
|
||||||
|
try self.drawStyledContent(arena, buf, width, height, header_lines);
|
||||||
|
|
||||||
|
// Calculate chart area (below the header, leaving room for details below)
|
||||||
|
const header_rows: u16 = @intCast(@min(header_lines.len, height));
|
||||||
|
const detail_rows: u16 = 10; // reserve rows for quote details below chart
|
||||||
|
const chart_rows = height -| header_rows -| detail_rows;
|
||||||
|
if (chart_rows < 8) return; // not enough space
|
||||||
|
|
||||||
|
// Compute pixel dimensions from cell size
|
||||||
|
// cell_size may be 0 if terminal hasn't reported pixel dimensions yet
|
||||||
|
const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8;
|
||||||
|
const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16;
|
||||||
|
const label_cols: u16 = 10; // columns reserved for axis labels on the right
|
||||||
|
const chart_cols = width -| 2 -| label_cols; // 1 col left margin + label area on right
|
||||||
|
if (chart_cols == 0) return;
|
||||||
|
const px_w: u32 = @as(u32, chart_cols) * cell_w;
|
||||||
|
const px_h: u32 = @as(u32, chart_rows) * cell_h;
|
||||||
|
|
||||||
|
if (px_w < 100 or px_h < 100) return;
|
||||||
|
// Apply resolution cap from chart config
|
||||||
|
const capped_w = @min(px_w, self.chart_config.max_width);
|
||||||
|
const capped_h = @min(px_h, self.chart_config.max_height);
|
||||||
|
|
||||||
|
// Check if we need to re-render the chart image
|
||||||
|
const symbol_changed = self.chart_symbol_len != self.symbol.len or
|
||||||
|
!std.mem.eql(u8, self.chart_symbol[0..self.chart_symbol_len], self.symbol);
|
||||||
|
const tf_changed = self.chart_timeframe_rendered == null or self.chart_timeframe_rendered.? != self.chart_timeframe;
|
||||||
|
|
||||||
|
if (self.chart_dirty or symbol_changed or tf_changed) {
|
||||||
|
// Free old image
|
||||||
|
if (self.chart_image_id) |old_id| {
|
||||||
|
if (self.vx_app) |va| {
|
||||||
|
va.vx.freeImage(va.tty.writer(), old_id);
|
||||||
|
}
|
||||||
|
self.chart_image_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render and transmit — use the app's main allocator, NOT the arena,
|
||||||
|
// because z2d allocates large pixel buffers that would bloat the arena.
|
||||||
|
if (self.vx_app) |va| {
|
||||||
|
const chart_result = chart_mod.renderChart(
|
||||||
|
self.allocator,
|
||||||
|
c,
|
||||||
|
self.chart_timeframe,
|
||||||
|
capped_w,
|
||||||
|
capped_h,
|
||||||
|
th,
|
||||||
|
) catch |err| {
|
||||||
|
self.chart_dirty = false;
|
||||||
|
var err_buf: [128]u8 = undefined;
|
||||||
|
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
|
||||||
|
self.setStatus(msg);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer self.allocator.free(chart_result.rgb_data);
|
||||||
|
|
||||||
|
// Base64-encode and transmit raw RGB data directly via Kitty protocol.
|
||||||
|
// This avoids the PNG encode → file write → file read → PNG decode roundtrip.
|
||||||
|
const base64_enc = std.base64.standard.Encoder;
|
||||||
|
const b64_buf = self.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
|
||||||
|
self.chart_dirty = false;
|
||||||
|
self.setStatus("Chart: base64 alloc failed");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer self.allocator.free(b64_buf);
|
||||||
|
const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data);
|
||||||
|
|
||||||
|
const img = va.vx.transmitPreEncodedImage(
|
||||||
|
va.tty.writer(),
|
||||||
|
encoded,
|
||||||
|
chart_result.width,
|
||||||
|
chart_result.height,
|
||||||
|
.rgb,
|
||||||
|
) catch |err| {
|
||||||
|
self.chart_dirty = false;
|
||||||
|
var err_buf: [128]u8 = undefined;
|
||||||
|
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
|
||||||
|
self.setStatus(msg);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.chart_image_id = img.id;
|
||||||
|
self.chart_image_width = @intCast(chart_cols);
|
||||||
|
self.chart_image_height = chart_rows;
|
||||||
|
|
||||||
|
// Track what we rendered
|
||||||
|
const sym_len = @min(self.symbol.len, 16);
|
||||||
|
@memcpy(self.chart_symbol[0..sym_len], self.symbol[0..sym_len]);
|
||||||
|
self.chart_symbol_len = sym_len;
|
||||||
|
self.chart_timeframe_rendered = self.chart_timeframe;
|
||||||
|
self.chart_price_min = chart_result.price_min;
|
||||||
|
self.chart_price_max = chart_result.price_max;
|
||||||
|
self.chart_rsi_latest = chart_result.rsi_latest;
|
||||||
|
self.chart_dirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place the image in the cell buffer
|
||||||
|
if (self.chart_image_id) |img_id| {
|
||||||
|
// Place image at the first cell of the chart area
|
||||||
|
const chart_row_start: usize = header_rows;
|
||||||
|
const chart_col_start: usize = 1; // 1 col left margin
|
||||||
|
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
|
||||||
|
if (buf_idx < buf.len) {
|
||||||
|
buf[buf_idx] = .{
|
||||||
|
.char = .{ .grapheme = " " },
|
||||||
|
.style = th.contentStyle(),
|
||||||
|
.image = .{
|
||||||
|
.img_id = img_id,
|
||||||
|
.options = .{
|
||||||
|
.size = .{
|
||||||
|
.rows = self.chart_image_height,
|
||||||
|
.cols = self.chart_image_width,
|
||||||
|
},
|
||||||
|
.scale = .contain,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Axis labels (terminal text in the right margin) ───────────
|
||||||
|
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
|
||||||
|
// Map these to terminal rows to position labels.
|
||||||
|
const img_rows = self.chart_image_height;
|
||||||
|
const label_col: usize = @as(usize, chart_col_start) + @as(usize, self.chart_image_width) + 1;
|
||||||
|
const label_style = th.mutedStyle();
|
||||||
|
|
||||||
|
if (label_col + 8 <= width and img_rows >= 4 and self.chart_price_max > self.chart_price_min) {
|
||||||
|
// Price axis labels — evenly spaced across the price panel (top 72%)
|
||||||
|
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
|
||||||
|
const n_price_labels: usize = 5;
|
||||||
|
for (0..n_price_labels) |i| {
|
||||||
|
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1));
|
||||||
|
const price_val = self.chart_price_max - frac * (self.chart_price_max - self.chart_price_min);
|
||||||
|
const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
|
||||||
|
const row: usize = @intFromFloat(@round(row_f));
|
||||||
|
if (row >= height) continue;
|
||||||
|
|
||||||
|
var lbl_buf: [16]u8 = undefined;
|
||||||
|
const lbl = fmt.fmtMoneyAbs(&lbl_buf, price_val);
|
||||||
|
const start_idx = row * @as(usize, width) + label_col;
|
||||||
|
for (lbl, 0..) |ch, ci| {
|
||||||
|
const idx = start_idx + ci;
|
||||||
|
if (idx < buf.len and label_col + ci < width) {
|
||||||
|
buf[idx] = .{
|
||||||
|
.char = .{ .grapheme = glyph(ch) },
|
||||||
|
.style = label_style,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSI axis labels — positioned within the RSI panel (bottom 20%, after 80% offset)
|
||||||
|
const rsi_panel_start_f = @as(f64, @floatFromInt(img_rows)) * 0.80;
|
||||||
|
const rsi_panel_h = @as(f64, @floatFromInt(img_rows)) * 0.20;
|
||||||
|
const rsi_labels = [_]struct { val: f64, label: []const u8 }{
|
||||||
|
.{ .val = 70, .label = "70" },
|
||||||
|
.{ .val = 50, .label = "50" },
|
||||||
|
.{ .val = 30, .label = "30" },
|
||||||
|
};
|
||||||
|
for (rsi_labels) |rl| {
|
||||||
|
// RSI maps 0-100 top-to-bottom within the RSI panel
|
||||||
|
const rsi_frac = 1.0 - (rl.val / 100.0);
|
||||||
|
const row_f = @as(f64, @floatFromInt(chart_row_start)) + rsi_panel_start_f + rsi_frac * rsi_panel_h;
|
||||||
|
const row: usize = @intFromFloat(@round(row_f));
|
||||||
|
if (row >= height) continue;
|
||||||
|
|
||||||
|
const start_idx = row * @as(usize, width) + label_col;
|
||||||
|
for (rl.label, 0..) |ch, ci| {
|
||||||
|
const idx = start_idx + ci;
|
||||||
|
if (idx < buf.len and label_col + ci < width) {
|
||||||
|
buf[idx] = .{
|
||||||
|
.char = .{ .grapheme = glyph(ch) },
|
||||||
|
.style = label_style,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render quote details below the chart image as styled text
|
||||||
|
const detail_start_row = header_rows + self.chart_image_height;
|
||||||
|
if (detail_start_row + 8 < height) {
|
||||||
|
var detail_lines: std.ArrayList(StyledLine) = .empty;
|
||||||
|
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
const latest = c[c.len - 1];
|
||||||
|
const quote_data = self.quote;
|
||||||
|
const price = if (quote_data) |q| q.close else latest.close;
|
||||||
|
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
|
||||||
|
|
||||||
|
try buildDetailColumns(self, arena, &detail_lines, latest, quote_data, price, prev_close);
|
||||||
|
|
||||||
|
// Write detail lines into the buffer below the image
|
||||||
|
const detail_buf_start = detail_start_row * @as(usize, width);
|
||||||
|
const remaining_height = height - @as(u16, @intCast(detail_start_row));
|
||||||
|
const detail_slice = try detail_lines.toOwnedSlice(arena);
|
||||||
|
if (detail_buf_start < buf.len) {
|
||||||
|
try self.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
|
const th = self.theme;
|
||||||
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
|
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
if (self.symbol.len == 0) {
|
||||||
|
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ago_buf: [16]u8 = undefined;
|
||||||
|
if (self.quote != null and self.quote_timestamp > 0) {
|
||||||
|
const ago_str = fmt.fmtTimeAgo(&ago_buf, self.quote_timestamp);
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ self.symbol, ago_str }), .style = th.headerStyle() });
|
||||||
|
} else if (self.candle_last_date) |d| {
|
||||||
|
var cdate_buf: [10]u8 = undefined;
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {s})", .{ self.symbol, d.format(&cdate_buf) }), .style = th.headerStyle() });
|
||||||
|
} else {
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{self.symbol}), .style = th.headerStyle() });
|
||||||
|
}
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
if (self.candles == null and !self.perf_loaded) self.loadPerfData();
|
||||||
|
|
||||||
|
// Use stored real-time quote if available (fetched on manual refresh)
|
||||||
|
const quote_data = self.quote;
|
||||||
|
|
||||||
|
const c = self.candles orelse {
|
||||||
|
if (quote_data) |q| {
|
||||||
|
// No candle data but have a quote - show it
|
||||||
|
var qclose_buf: [24]u8 = undefined;
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&qclose_buf, q.close)}), .style = th.contentStyle() });
|
||||||
|
{
|
||||||
|
var chg_buf: [64]u8 = undefined;
|
||||||
|
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
|
||||||
|
}
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{self.symbol}), .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
};
|
||||||
|
if (c.len == 0) {
|
||||||
|
try lines.append(arena, .{ .text = " No candle data.", .style = th.mutedStyle() });
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use real-time quote price if available, otherwise latest candle
|
||||||
|
const price = if (quote_data) |q| q.close else c[c.len - 1].close;
|
||||||
|
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
|
||||||
|
const latest = c[c.len - 1];
|
||||||
|
|
||||||
|
try buildDetailColumns(self, arena, &lines, latest, quote_data, price, prev_close);
|
||||||
|
|
||||||
|
// Braille sparkline chart of recent 60 trading days
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
const chart_days: usize = @min(c.len, 60);
|
||||||
|
const chart_data = c[c.len - chart_days ..];
|
||||||
|
try tui.renderBrailleToStyledLines(arena, &lines, chart_data, th);
|
||||||
|
|
||||||
|
// Recent history table
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
try lines.append(arena, .{ .text = " Recent History:", .style = th.headerStyle() });
|
||||||
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() });
|
||||||
|
|
||||||
|
const start_idx = if (c.len > 20) c.len - 20 else 0;
|
||||||
|
for (c[start_idx..]) |candle| {
|
||||||
|
var row_buf: [128]u8 = undefined;
|
||||||
|
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
|
||||||
|
try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change });
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quote detail columns (price/OHLCV | ETF stats | sectors | holdings) ──
|
||||||
|
|
||||||
|
const Column = struct {
|
||||||
|
texts: std.ArrayList([]const u8),
|
||||||
|
styles: std.ArrayList(vaxis.Style),
|
||||||
|
width: usize, // fixed column width for padding
|
||||||
|
|
||||||
|
fn init() Column {
|
||||||
|
return .{
|
||||||
|
.texts = .empty,
|
||||||
|
.styles = .empty,
|
||||||
|
.width = 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(self: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void {
|
||||||
|
try self.texts.append(arena, text);
|
||||||
|
try self.styles.append(arena, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn len(self: *const Column) usize {
|
||||||
|
return self.texts.items.len;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fn buildDetailColumns(
|
||||||
|
self: *App,
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
lines: *std.ArrayList(StyledLine),
|
||||||
|
latest: zfin.Candle,
|
||||||
|
quote_data: ?zfin.Quote,
|
||||||
|
price: f64,
|
||||||
|
prev_close: f64,
|
||||||
|
) !void {
|
||||||
|
const th = self.theme;
|
||||||
|
var date_buf: [10]u8 = undefined;
|
||||||
|
var close_buf: [24]u8 = undefined;
|
||||||
|
var vol_buf: [32]u8 = undefined;
|
||||||
|
|
||||||
|
// Column 1: Price/OHLCV
|
||||||
|
var col1 = Column.init();
|
||||||
|
col1.width = 30;
|
||||||
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), th.contentStyle());
|
||||||
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&close_buf, price)}), th.contentStyle());
|
||||||
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle());
|
||||||
|
try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
|
||||||
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
|
||||||
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Volume: {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), th.mutedStyle());
|
||||||
|
if (prev_close > 0) {
|
||||||
|
const change = price - prev_close;
|
||||||
|
const pct = (change / prev_close) * 100.0;
|
||||||
|
var chg_buf: [64]u8 = undefined;
|
||||||
|
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||||
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), change_style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns 2-4: ETF profile (only for actual ETFs)
|
||||||
|
var col2 = Column.init(); // ETF stats
|
||||||
|
col2.width = 22;
|
||||||
|
var col3 = Column.init(); // Sectors
|
||||||
|
col3.width = 26;
|
||||||
|
var col4 = Column.init(); // Top holdings
|
||||||
|
col4.width = 30;
|
||||||
|
|
||||||
|
if (self.etf_profile) |profile| {
|
||||||
|
// Col 2: ETF key stats
|
||||||
|
try col2.add(arena, "ETF Profile", th.headerStyle());
|
||||||
|
if (profile.expense_ratio) |er| {
|
||||||
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Expense: {d:.2}%", .{er * 100.0}), th.contentStyle());
|
||||||
|
}
|
||||||
|
if (profile.net_assets) |na| {
|
||||||
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Assets: ${s}", .{std.mem.trimRight(u8, &fmt.fmtLargeNum(na), &.{' '})}), th.contentStyle());
|
||||||
|
}
|
||||||
|
if (profile.dividend_yield) |dy| {
|
||||||
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Yield: {d:.2}%", .{dy * 100.0}), th.contentStyle());
|
||||||
|
}
|
||||||
|
if (profile.total_holdings) |th_val| {
|
||||||
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Holdings: {d}", .{th_val}), th.mutedStyle());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Col 3: Sector allocation
|
||||||
|
if (profile.sectors) |sectors| {
|
||||||
|
if (sectors.len > 0) {
|
||||||
|
try col3.add(arena, "Sectors", th.headerStyle());
|
||||||
|
const show = @min(sectors.len, 7);
|
||||||
|
for (sectors[0..show]) |sec| {
|
||||||
|
var title_buf: [64]u8 = undefined;
|
||||||
|
const title_name = fmt.toTitleCase(&title_buf, sec.name);
|
||||||
|
const name = if (title_name.len > 20) title_name[0..20] else title_name;
|
||||||
|
try col3.add(arena, try std.fmt.allocPrint(arena, " {d:>5.1}% {s}", .{ sec.weight * 100.0, name }), th.contentStyle());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Col 4: Top holdings
|
||||||
|
if (profile.holdings) |holdings| {
|
||||||
|
if (holdings.len > 0) {
|
||||||
|
try col4.add(arena, "Top Holdings", th.headerStyle());
|
||||||
|
const show = @min(holdings.len, 7);
|
||||||
|
for (holdings[0..show]) |h| {
|
||||||
|
const sym_str = h.symbol orelse "--";
|
||||||
|
try col4.add(arena, try std.fmt.allocPrint(arena, " {s:>6} {d:>5.1}%", .{ sym_str, h.weight * 100.0 }), th.contentStyle());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all columns into grapheme-based StyledLines
|
||||||
|
const gap: usize = 3;
|
||||||
|
const bg_style = vaxis.Style{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(th.bg) };
|
||||||
|
const cols = [_]*const Column{ &col1, &col2, &col3, &col4 };
|
||||||
|
var max_rows: usize = 0;
|
||||||
|
for (cols) |col| max_rows = @max(max_rows, col.len());
|
||||||
|
|
||||||
|
// Total max width for allocation
|
||||||
|
const max_width = col1.width + gap + col2.width + gap + col3.width + gap + col4.width + 4;
|
||||||
|
|
||||||
|
for (0..max_rows) |ri| {
|
||||||
|
const graphemes = try arena.alloc([]const u8, max_width);
|
||||||
|
const col_styles = try arena.alloc(vaxis.Style, max_width);
|
||||||
|
var pos: usize = 0;
|
||||||
|
|
||||||
|
for (cols, 0..) |col, ci| {
|
||||||
|
if (ci > 0 and col.len() == 0) continue; // skip empty columns entirely
|
||||||
|
if (ci > 0) {
|
||||||
|
// Gap between columns
|
||||||
|
for (0..gap) |_| {
|
||||||
|
if (pos < max_width) {
|
||||||
|
graphemes[pos] = " ";
|
||||||
|
col_styles[pos] = bg_style;
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ri < col.len()) {
|
||||||
|
const text = col.texts.items[ri];
|
||||||
|
const style = col.styles.items[ri];
|
||||||
|
// Write text characters
|
||||||
|
for (0..@min(text.len, col.width)) |ci2| {
|
||||||
|
if (pos < max_width) {
|
||||||
|
graphemes[pos] = glyph(text[ci2]);
|
||||||
|
col_styles[pos] = style;
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pad to column width
|
||||||
|
if (text.len < col.width) {
|
||||||
|
for (0..col.width - text.len) |_| {
|
||||||
|
if (pos < max_width) {
|
||||||
|
graphemes[pos] = " ";
|
||||||
|
col_styles[pos] = bg_style;
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Empty row in this column - pad full width
|
||||||
|
for (0..col.width) |_| {
|
||||||
|
if (pos < max_width) {
|
||||||
|
graphemes[pos] = " ";
|
||||||
|
col_styles[pos] = bg_style;
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try lines.append(arena, .{
|
||||||
|
.text = "",
|
||||||
|
.style = bg_style,
|
||||||
|
.graphemes = graphemes[0..pos],
|
||||||
|
.cell_styles = col_styles[0..pos],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue