Compare commits
No commits in common. "3ae53f15b000aa48eb7aa3336b235706857d53b4" and "2ac4156bc1227ca7691a22a35411ff77064ae697" have entirely different histories.
3ae53f15b0
...
2ac4156bc1
8 changed files with 287 additions and 1000 deletions
|
|
@ -1,6 +1,5 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const zfin = @import("../root.zig");
|
const zfin = @import("../root.zig");
|
||||||
const srf = @import("srf");
|
|
||||||
pub const fmt = @import("../format.zig");
|
pub const fmt = @import("../format.zig");
|
||||||
|
|
||||||
// ── Default CLI colors (match TUI default Monokai theme) ─────
|
// ── Default CLI colors (match TUI default Monokai theme) ─────
|
||||||
|
|
@ -320,22 +319,20 @@ pub fn buildPortfolioData(
|
||||||
|
|
||||||
// ── Watchlist loading ────────────────────────────────────────
|
// ── Watchlist loading ────────────────────────────────────────
|
||||||
|
|
||||||
/// Load a watchlist SRF file containing symbol records.
|
/// Load a watchlist file using the library's SRF deserializer.
|
||||||
/// Returns owned symbol strings. Returns null if file missing or empty.
|
/// Returns owned symbol strings. Returns null if file missing or empty.
|
||||||
pub fn loadWatchlist(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 {
|
pub fn loadWatchlist(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 {
|
||||||
const file_data = std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch return null;
|
const file_data = std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch return null;
|
||||||
defer allocator.free(file_data);
|
defer allocator.free(file_data);
|
||||||
|
|
||||||
const WatchEntry = struct { symbol: []const u8 };
|
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch return null;
|
||||||
|
defer portfolio.deinit();
|
||||||
|
|
||||||
var reader = std.Io.Reader.fixed(file_data);
|
if (portfolio.lots.len == 0) return null;
|
||||||
var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return null;
|
|
||||||
defer it.deinit();
|
|
||||||
|
|
||||||
var syms: std.ArrayList([]const u8) = .empty;
|
var syms: std.ArrayList([]const u8) = .empty;
|
||||||
while (it.next() catch null) |fields| {
|
for (portfolio.lots) |lot| {
|
||||||
const entry = fields.to(WatchEntry) catch continue;
|
const duped = allocator.dupe(u8, lot.symbol) catch continue;
|
||||||
const duped = allocator.dupe(u8, entry.symbol) catch continue;
|
|
||||||
syms.append(allocator, duped) catch {
|
syms.append(allocator, duped) catch {
|
||||||
allocator.free(duped);
|
allocator.free(duped);
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,6 @@ const std = @import("std");
|
||||||
const zfin = @import("../root.zig");
|
const zfin = @import("../root.zig");
|
||||||
const cli = @import("common.zig");
|
const cli = @import("common.zig");
|
||||||
const fmt = cli.fmt;
|
const fmt = cli.fmt;
|
||||||
const views = @import("../views/portfolio_sections.zig");
|
|
||||||
|
|
||||||
/// Map a semantic StyleIntent to CLI ANSI foreground color.
|
|
||||||
fn setIntentFg(out: *std.Io.Writer, color: bool, intent: fmt.StyleIntent) !void {
|
|
||||||
if (!color) return;
|
|
||||||
switch (intent) {
|
|
||||||
.normal => try cli.reset(out, color),
|
|
||||||
.muted => try cli.setFg(out, color, cli.CLR_MUTED),
|
|
||||||
.positive => try cli.setFg(out, color, cli.CLR_POSITIVE),
|
|
||||||
.negative => try cli.setFg(out, color, cli.CLR_NEGATIVE),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {
|
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {
|
||||||
// Load portfolio from SRF file
|
// Load portfolio from SRF file
|
||||||
|
|
@ -376,35 +364,36 @@ pub fn display(
|
||||||
|
|
||||||
// Options section
|
// Options section
|
||||||
if (portfolio.hasType(.option)) {
|
if (portfolio.hasType(.option)) {
|
||||||
var prepared_opts = try views.Options.init(allocator, portfolio.lots, null);
|
|
||||||
defer prepared_opts.deinit();
|
|
||||||
if (prepared_opts.items.len > 0) {
|
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try cli.setBold(out, color);
|
try cli.setBold(out, color);
|
||||||
try out.print(" Options\n", .{});
|
try out.print(" Options\n", .{});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
try out.print(views.OptionsLayout.header ++ "\n", views.OptionsLayout.header_labels);
|
try out.print(" {s:<30} {s:>6} {s:>12} {s:>14} {s}\n", .{
|
||||||
try out.print(views.OptionsLayout.separator ++ "\n", views.OptionsLayout.separator_fills);
|
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
|
||||||
|
});
|
||||||
|
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14} {s:->10}\n", .{
|
||||||
|
"", "", "", "", "",
|
||||||
|
});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
|
|
||||||
var opt_total_premium: f64 = 0;
|
var opt_total_cost: f64 = 0;
|
||||||
for (prepared_opts.items) |po| {
|
for (portfolio.lots) |lot| {
|
||||||
opt_total_premium += po.premium;
|
if (lot.security_type != .option) continue;
|
||||||
const text = po.columns[0].text;
|
const qty = lot.shares;
|
||||||
const prem_start = po.premium_col_start;
|
const cost_per = lot.open_price;
|
||||||
const prem_end = @min(prem_start + views.OptionsLayout.premium_w, text.len);
|
const total_cost_opt = @abs(qty) * cost_per;
|
||||||
// Pre-premium portion
|
opt_total_cost += total_cost_opt;
|
||||||
try setIntentFg(out, color, po.row_style);
|
var cost_per_buf: [24]u8 = undefined;
|
||||||
try out.print("{s}", .{text[0..prem_start]});
|
var total_cost_buf: [24]u8 = undefined;
|
||||||
// Premium column
|
const acct: []const u8 = lot.account orelse "";
|
||||||
try setIntentFg(out, color, po.premium_style);
|
try out.print(" {s:<30} {d:>6.0} {s:>12} {s:>14} {s}\n", .{
|
||||||
try out.print("{s}", .{text[prem_start..prem_end]});
|
lot.symbol,
|
||||||
// Post-premium portion (account)
|
qty,
|
||||||
try setIntentFg(out, color, po.row_style);
|
fmt.fmtMoneyAbs(&cost_per_buf, cost_per),
|
||||||
if (prem_end < text.len) try out.print("{s}", .{text[prem_end..]});
|
fmt.fmtMoneyAbs(&total_cost_buf, total_cost_opt),
|
||||||
try cli.reset(out, color);
|
acct,
|
||||||
try out.print("\n", .{});
|
});
|
||||||
}
|
}
|
||||||
// Options total
|
// Options total
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
|
|
@ -412,31 +401,55 @@ pub fn display(
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
var opt_total_buf: [24]u8 = undefined;
|
var opt_total_buf: [24]u8 = undefined;
|
||||||
try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{
|
try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{
|
||||||
"", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_premium),
|
"", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_cost),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// CDs section
|
// CDs section
|
||||||
if (portfolio.hasType(.cd)) {
|
if (portfolio.hasType(.cd)) {
|
||||||
var prepared_cds = try views.CDs.init(allocator, portfolio.lots, null);
|
|
||||||
defer prepared_cds.deinit();
|
|
||||||
if (prepared_cds.items.len > 0) {
|
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try cli.setBold(out, color);
|
try cli.setBold(out, color);
|
||||||
try out.print(" Certificates of Deposit\n", .{});
|
try out.print(" Certificates of Deposit\n", .{});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
try out.print(views.CDsLayout.header ++ "\n", views.CDsLayout.header_labels);
|
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
|
||||||
try out.print(views.CDsLayout.separator ++ "\n", views.CDsLayout.separator_fills);
|
"CUSIP", "Face Value", "Rate", "Maturity", "Description",
|
||||||
|
});
|
||||||
|
try out.print(" {s:->12} {s:->14} {s:->7} {s:->10} {s:->30}\n", .{
|
||||||
|
"", "", "", "", "",
|
||||||
|
});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
|
|
||||||
|
// Collect and sort CDs by maturity date (earliest first)
|
||||||
|
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
|
||||||
|
defer cd_lots.deinit(allocator);
|
||||||
|
for (portfolio.lots) |lot| {
|
||||||
|
if (lot.security_type == .cd) {
|
||||||
|
try cd_lots.append(allocator, lot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
|
||||||
|
|
||||||
var cd_section_total: f64 = 0;
|
var cd_section_total: f64 = 0;
|
||||||
for (prepared_cds.items) |pc| {
|
for (cd_lots.items) |lot| {
|
||||||
cd_section_total += pc.lot.shares;
|
cd_section_total += lot.shares;
|
||||||
try setIntentFg(out, color, pc.row_style);
|
var face_buf: [24]u8 = undefined;
|
||||||
try out.print("{s}\n", .{pc.text});
|
var mat_buf: [10]u8 = undefined;
|
||||||
try cli.reset(out, color);
|
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
|
||||||
|
var rate_buf: [10]u8 = undefined;
|
||||||
|
const rate_str: []const u8 = if (lot.rate) |r|
|
||||||
|
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
|
||||||
|
else
|
||||||
|
"--";
|
||||||
|
const note_str: []const u8 = lot.note orelse "";
|
||||||
|
const note_display = if (note_str.len > 50) note_str[0..50] else note_str;
|
||||||
|
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
|
||||||
|
lot.symbol,
|
||||||
|
fmt.fmtMoneyAbs(&face_buf, lot.shares),
|
||||||
|
rate_str,
|
||||||
|
mat_str,
|
||||||
|
note_display,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// CD total
|
// CD total
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
|
|
@ -447,7 +460,6 @@ pub fn display(
|
||||||
"TOTAL", fmt.fmtMoneyAbs(&cd_total_buf, cd_section_total),
|
"TOTAL", fmt.fmtMoneyAbs(&cd_total_buf, cd_section_total),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Cash section
|
// Cash section
|
||||||
if (portfolio.hasType(.cash)) {
|
if (portfolio.hasType(.cash)) {
|
||||||
|
|
|
||||||
|
|
@ -385,26 +385,6 @@ pub fn lotMaturitySortFn(_: void, a: Lot, b: Lot) bool {
|
||||||
return ad < bd;
|
return ad < bd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort lots by maturity date (earliest first), then by symbol name.
|
|
||||||
/// Lots without maturity sort last.
|
|
||||||
pub fn lotMaturityThenSymbolSortFn(_: void, a: Lot, b: Lot) bool {
|
|
||||||
const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32);
|
|
||||||
const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32);
|
|
||||||
if (ad != bd) return ad < bd;
|
|
||||||
return std.mem.lessThan(u8, a.symbol, b.symbol);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared style intent ──────────────────────────────────────
|
|
||||||
|
|
||||||
/// Semantic style intent — renderers map this to platform-specific styles.
|
|
||||||
/// Used by view models (e.g. views/portfolio_sections.zig) and renderers.
|
|
||||||
pub const StyleIntent = enum {
|
|
||||||
normal, // default text
|
|
||||||
muted, // dim/secondary (expired items)
|
|
||||||
positive, // green (gains, premium received)
|
|
||||||
negative, // red (losses, premium paid)
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket.
|
/// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket.
|
||||||
pub const DripSummary = struct {
|
pub const DripSummary = struct {
|
||||||
lot_count: usize = 0,
|
lot_count: usize = 0,
|
||||||
|
|
|
||||||
|
|
@ -189,12 +189,6 @@ pub fn main() !u8 {
|
||||||
file_path = args[pi];
|
file_path = args[pi];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Auto-detect watchlist.srf in cwd if not explicitly provided (same as TUI)
|
|
||||||
if (watchlist_path == null) {
|
|
||||||
if (std.fs.cwd().access("watchlist.srf", .{})) |_| {
|
|
||||||
watchlist_path = "watchlist.srf";
|
|
||||||
} else |_| {}
|
|
||||||
}
|
|
||||||
try commands.portfolio.run(allocator, &svc, file_path, watchlist_path, force_refresh, color, out);
|
try commands.portfolio.run(allocator, &svc, file_path, watchlist_path, force_refresh, color, out);
|
||||||
} else if (std.mem.eql(u8, command, "lookup")) {
|
} else if (std.mem.eql(u8, command, "lookup")) {
|
||||||
if (args.len < 3) {
|
if (args.len < 3) {
|
||||||
|
|
|
||||||
202
src/tui.zig
202
src/tui.zig
|
|
@ -2,7 +2,6 @@ const std = @import("std");
|
||||||
const vaxis = @import("vaxis");
|
const vaxis = @import("vaxis");
|
||||||
const zfin = @import("root.zig");
|
const zfin = @import("root.zig");
|
||||||
const fmt = @import("format.zig");
|
const fmt = @import("format.zig");
|
||||||
const views = @import("views/portfolio_sections.zig");
|
|
||||||
const cli = @import("commands/common.zig");
|
const cli = @import("commands/common.zig");
|
||||||
const keybinds = @import("tui/keybinds.zig");
|
const keybinds = @import("tui/keybinds.zig");
|
||||||
const theme_mod = @import("tui/theme.zig");
|
const theme_mod = @import("tui/theme.zig");
|
||||||
|
|
@ -94,7 +93,6 @@ pub const InputMode = enum {
|
||||||
normal,
|
normal,
|
||||||
symbol_input,
|
symbol_input,
|
||||||
help,
|
help,
|
||||||
account_picker,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const StyledLine = struct {
|
pub const StyledLine = struct {
|
||||||
|
|
@ -182,13 +180,6 @@ pub const PortfolioRow = struct {
|
||||||
drip_avg_cost: f64 = 0,
|
drip_avg_cost: f64 = 0,
|
||||||
drip_date_first: ?zfin.Date = null,
|
drip_date_first: ?zfin.Date = null,
|
||||||
drip_date_last: ?zfin.Date = null,
|
drip_date_last: ?zfin.Date = null,
|
||||||
/// Pre-formatted text from view model (options and CDs)
|
|
||||||
prepared_text: ?[]const u8 = null,
|
|
||||||
/// Semantic styles from view model
|
|
||||||
row_style: fmt.StyleIntent = .normal,
|
|
||||||
premium_style: fmt.StyleIntent = .normal,
|
|
||||||
/// Column offset for premium alt-style coloring (options only)
|
|
||||||
premium_col_start: usize = 0,
|
|
||||||
|
|
||||||
const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary };
|
const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary };
|
||||||
};
|
};
|
||||||
|
|
@ -297,8 +288,6 @@ pub const App = struct {
|
||||||
cash_expanded: bool = false, // whether cash section is expanded to show per-account
|
cash_expanded: bool = false, // whether cash section is expanded to show per-account
|
||||||
illiquid_expanded: bool = false, // whether illiquid section is expanded to show per-asset
|
illiquid_expanded: bool = false, // whether illiquid section is expanded to show per-asset
|
||||||
portfolio_rows: std.ArrayList(PortfolioRow) = .empty,
|
portfolio_rows: std.ArrayList(PortfolioRow) = .empty,
|
||||||
prepared_options: ?views.Options = null,
|
|
||||||
prepared_cds: ?views.CDs = null,
|
|
||||||
portfolio_header_lines: usize = 0, // number of styled lines before data rows
|
portfolio_header_lines: usize = 0, // number of styled lines before data rows
|
||||||
portfolio_line_to_row: [256]usize = [_]usize{0} ** 256, // maps styled line index -> portfolio_rows index
|
portfolio_line_to_row: [256]usize = [_]usize{0} ** 256, // maps styled line index -> portfolio_rows index
|
||||||
portfolio_line_count: usize = 0, // total styled lines in portfolio view
|
portfolio_line_count: usize = 0, // total styled lines in portfolio view
|
||||||
|
|
@ -307,11 +296,6 @@ pub const App = struct {
|
||||||
watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render)
|
watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render)
|
||||||
prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress)
|
prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress)
|
||||||
|
|
||||||
// Account filter state
|
|
||||||
account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts)
|
|
||||||
account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (borrowed from portfolio)
|
|
||||||
account_picker_cursor: usize = 0, // cursor position in picker (0 = "All accounts")
|
|
||||||
|
|
||||||
// Options navigation (inline expand/collapse like portfolio)
|
// Options navigation (inline expand/collapse like portfolio)
|
||||||
options_cursor: usize = 0, // selected row in flattened options view
|
options_cursor: usize = 0, // selected row in flattened options view
|
||||||
options_expanded: [64]bool = [_]bool{false} ** 64, // which expirations are expanded
|
options_expanded: [64]bool = [_]bool{false} ** 64, // which expirations are expanded
|
||||||
|
|
@ -384,9 +368,6 @@ pub const App = struct {
|
||||||
if (self.mode == .symbol_input) {
|
if (self.mode == .symbol_input) {
|
||||||
return self.handleInputKey(ctx, key);
|
return self.handleInputKey(ctx, key);
|
||||||
}
|
}
|
||||||
if (self.mode == .account_picker) {
|
|
||||||
return self.handleAccountPickerKey(ctx, key);
|
|
||||||
}
|
|
||||||
if (self.mode == .help) {
|
if (self.mode == .help) {
|
||||||
self.mode = .normal;
|
self.mode = .normal;
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
|
|
@ -404,42 +385,6 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void {
|
fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void {
|
||||||
// Account picker mouse handling
|
|
||||||
if (self.mode == .account_picker) {
|
|
||||||
const total_items = self.account_list.items.len + 1;
|
|
||||||
switch (mouse.button) {
|
|
||||||
.wheel_up => {
|
|
||||||
if (self.shouldDebounceWheel()) return;
|
|
||||||
if (self.account_picker_cursor > 0)
|
|
||||||
self.account_picker_cursor -= 1;
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
},
|
|
||||||
.wheel_down => {
|
|
||||||
if (self.shouldDebounceWheel()) return;
|
|
||||||
if (total_items > 0 and self.account_picker_cursor < total_items - 1)
|
|
||||||
self.account_picker_cursor += 1;
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
},
|
|
||||||
.left => {
|
|
||||||
if (mouse.type != .press) return;
|
|
||||||
// Map click row to picker item index.
|
|
||||||
// mouse.row maps directly to content line index
|
|
||||||
// (same convention as portfolio click handling).
|
|
||||||
const content_row = @as(usize, @intCast(mouse.row));
|
|
||||||
if (content_row >= portfolio_tab.account_picker_header_lines) {
|
|
||||||
const item_idx = content_row - portfolio_tab.account_picker_header_lines;
|
|
||||||
if (item_idx < total_items) {
|
|
||||||
self.account_picker_cursor = item_idx;
|
|
||||||
self.applyAccountPickerSelection();
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
else => {},
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (mouse.button) {
|
switch (mouse.button) {
|
||||||
.wheel_up => {
|
.wheel_up => {
|
||||||
self.moveBy(-3);
|
self.moveBy(-3);
|
||||||
|
|
@ -615,95 +560,9 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles keypresses in account_picker mode.
|
|
||||||
fn handleAccountPickerKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
|
|
||||||
const total_items = self.account_list.items.len + 1; // +1 for "All accounts"
|
|
||||||
|
|
||||||
// Cancel: return to normal mode without changing the filter
|
|
||||||
if (key.codepoint == vaxis.Key.escape) {
|
|
||||||
self.mode = .normal;
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm: apply the selected account filter
|
|
||||||
if (key.codepoint == vaxis.Key.enter) {
|
|
||||||
self.applyAccountPickerSelection();
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the keymap for navigation actions
|
|
||||||
const action = self.keymap.matchAction(key) orelse return;
|
|
||||||
switch (action) {
|
|
||||||
.select_next => {
|
|
||||||
if (total_items > 0 and self.account_picker_cursor < total_items - 1)
|
|
||||||
self.account_picker_cursor += 1;
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
},
|
|
||||||
.select_prev => {
|
|
||||||
if (self.account_picker_cursor > 0)
|
|
||||||
self.account_picker_cursor -= 1;
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
},
|
|
||||||
.scroll_top => {
|
|
||||||
self.account_picker_cursor = 0;
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
},
|
|
||||||
.scroll_bottom => {
|
|
||||||
if (total_items > 0)
|
|
||||||
self.account_picker_cursor = total_items - 1;
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
},
|
|
||||||
else => {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply the current account picker selection and return to normal mode.
|
|
||||||
fn applyAccountPickerSelection(self: *App) void {
|
|
||||||
if (self.account_picker_cursor == 0) {
|
|
||||||
// "All accounts" — clear filter
|
|
||||||
self.setAccountFilter(null);
|
|
||||||
} else {
|
|
||||||
const idx = self.account_picker_cursor - 1;
|
|
||||||
if (idx < self.account_list.items.len) {
|
|
||||||
self.setAccountFilter(self.account_list.items[idx]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.mode = .normal;
|
|
||||||
self.cursor = 0;
|
|
||||||
self.scroll_offset = 0;
|
|
||||||
portfolio_tab.rebuildPortfolioRows(self);
|
|
||||||
|
|
||||||
if (self.account_filter) |af| {
|
|
||||||
var tmp_buf: [256]u8 = undefined;
|
|
||||||
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered: {s}", .{af}) catch "Filtered";
|
|
||||||
self.setStatus(msg);
|
|
||||||
} else {
|
|
||||||
self.setStatus("Filter cleared: showing all accounts");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set or clear the account filter. Owns the string via allocator.
|
|
||||||
pub fn setAccountFilter(self: *App, name: ?[]const u8) void {
|
|
||||||
// Free the old owned copy
|
|
||||||
if (self.account_filter) |old| self.allocator.free(old);
|
|
||||||
if (name) |n| {
|
|
||||||
self.account_filter = self.allocator.dupe(u8, n) catch null;
|
|
||||||
} else {
|
|
||||||
self.account_filter = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
|
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
|
||||||
// Escape: clear account filter on portfolio tab, no-op otherwise
|
// Escape: no special behavior needed (options is now inline)
|
||||||
if (key.codepoint == vaxis.Key.escape) {
|
if (key.codepoint == vaxis.Key.escape) {
|
||||||
if (self.active_tab == .portfolio and self.account_filter != null) {
|
|
||||||
self.setAccountFilter(null);
|
|
||||||
self.cursor = 0;
|
|
||||||
self.scroll_offset = 0;
|
|
||||||
portfolio_tab.rebuildPortfolioRows(self);
|
|
||||||
self.setStatus("Filter cleared: showing all accounts");
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -895,32 +754,8 @@ pub const App = struct {
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.account_filter => {
|
|
||||||
if (self.active_tab == .portfolio and self.portfolio != null) {
|
|
||||||
self.mode = .account_picker;
|
|
||||||
// Position cursor on the currently-active filter (or 0 for "All")
|
|
||||||
self.account_picker_cursor = 0;
|
|
||||||
if (self.account_filter) |af| {
|
|
||||||
for (self.account_list.items, 0..) |acct, ai| {
|
|
||||||
if (std.mem.eql(u8, acct, af)) {
|
|
||||||
self.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts"
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if this wheel event should be suppressed (too close to the last one).
|
|
||||||
fn shouldDebounceWheel(self: *App) bool {
|
|
||||||
const now = std.time.nanoTimestamp();
|
|
||||||
if (now - self.last_wheel_ns < 1 * std.time.ns_per_ms) return true;
|
|
||||||
self.last_wheel_ns = now;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move cursor/scroll. Positive = down, negative = up.
|
/// Move cursor/scroll. Positive = down, negative = up.
|
||||||
/// For portfolio and options tabs, moves the row cursor by 1 with
|
/// For portfolio and options tabs, moves the row cursor by 1 with
|
||||||
|
|
@ -928,7 +763,9 @@ pub const App = struct {
|
||||||
/// For other tabs, adjusts scroll_offset by |n|.
|
/// For other tabs, adjusts scroll_offset by |n|.
|
||||||
fn moveBy(self: *App, n: isize) void {
|
fn moveBy(self: *App, n: isize) void {
|
||||||
if (self.active_tab == .portfolio or self.active_tab == .options) {
|
if (self.active_tab == .portfolio or self.active_tab == .options) {
|
||||||
if (self.shouldDebounceWheel()) return;
|
const now = std.time.nanoTimestamp();
|
||||||
|
if (now - self.last_wheel_ns < 1 * std.time.ns_per_ms) return;
|
||||||
|
self.last_wheel_ns = now;
|
||||||
if (self.active_tab == .portfolio) {
|
if (self.active_tab == .portfolio) {
|
||||||
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
|
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
|
||||||
self.ensureCursorVisible();
|
self.ensureCursorVisible();
|
||||||
|
|
@ -1328,13 +1165,6 @@ pub const App = struct {
|
||||||
self.portfolio_summary = null;
|
self.portfolio_summary = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn freePreparedSections(self: *App) void {
|
|
||||||
if (self.prepared_options) |*opts| opts.deinit();
|
|
||||||
self.prepared_options = null;
|
|
||||||
if (self.prepared_cds) |*cds| cds.deinit();
|
|
||||||
self.prepared_cds = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deinitData(self: *App) void {
|
fn deinitData(self: *App) void {
|
||||||
self.freeCandles();
|
self.freeCandles();
|
||||||
self.freeDividends();
|
self.freeDividends();
|
||||||
|
|
@ -1342,11 +1172,8 @@ pub const App = struct {
|
||||||
self.freeOptions();
|
self.freeOptions();
|
||||||
self.freeEtfProfile();
|
self.freeEtfProfile();
|
||||||
self.freePortfolioSummary();
|
self.freePortfolioSummary();
|
||||||
self.freePreparedSections();
|
|
||||||
self.portfolio_rows.deinit(self.allocator);
|
self.portfolio_rows.deinit(self.allocator);
|
||||||
self.options_rows.deinit(self.allocator);
|
self.options_rows.deinit(self.allocator);
|
||||||
self.account_list.deinit(self.allocator);
|
|
||||||
if (self.account_filter) |af| self.allocator.free(af);
|
|
||||||
if (self.watchlist_prices) |*wp| wp.deinit();
|
if (self.watchlist_prices) |*wp| wp.deinit();
|
||||||
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
|
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
|
||||||
if (self.classification_map) |*cm| cm.deinit();
|
if (self.classification_map) |*cm| cm.deinit();
|
||||||
|
|
@ -1448,8 +1275,6 @@ pub const App = struct {
|
||||||
|
|
||||||
if (self.mode == .help) {
|
if (self.mode == .help) {
|
||||||
try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
|
try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
|
||||||
} else if (self.mode == .account_picker) {
|
|
||||||
try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height);
|
|
||||||
} else {
|
} else {
|
||||||
switch (self.active_tab) {
|
switch (self.active_tab) {
|
||||||
.portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height),
|
.portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height),
|
||||||
|
|
@ -1535,31 +1360,14 @@ pub const App = struct {
|
||||||
buf[hint_start + i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = hint_style };
|
buf[hint_start + i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = hint_style };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (self.mode == .account_picker) {
|
|
||||||
const prompt_style = t.inputStyle();
|
|
||||||
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });
|
|
||||||
const hint = " j/k=navigate Enter=select Esc=cancel Click=select ";
|
|
||||||
for (0..@min(hint.len, width)) |i| {
|
|
||||||
buf[i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = prompt_style };
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const status_style = t.statusStyle();
|
const status_style = t.statusStyle();
|
||||||
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
|
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
|
||||||
// Show account filter indicator when active, appended to status message
|
|
||||||
if (self.account_filter != null and self.active_tab == .portfolio) {
|
|
||||||
const af = self.account_filter.?;
|
|
||||||
const msg = self.getStatus();
|
|
||||||
const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg;
|
|
||||||
for (0..@min(filter_text.len, width)) |i| {
|
|
||||||
buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const msg = self.getStatus();
|
const msg = self.getStatus();
|
||||||
for (0..@min(msg.len, width)) |i| {
|
for (0..@min(msg.len, width)) |i| {
|
||||||
buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
|
buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
|
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
|
||||||
}
|
}
|
||||||
|
|
@ -1627,7 +1435,7 @@ pub const App = struct {
|
||||||
"Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM",
|
"Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM",
|
||||||
"Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", "Filter +/- 7 NTM",
|
"Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", "Filter +/- 7 NTM",
|
||||||
"Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe", "Chart: prev timeframe",
|
"Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe", "Chart: prev timeframe",
|
||||||
"Sort: next column", "Sort: prev column", "Sort: reverse order", "Account filter (portfolio)",
|
"Sort: next column", "Sort: prev column", "Sort: reverse order",
|
||||||
};
|
};
|
||||||
|
|
||||||
for (actions, 0..) |action, ai| {
|
for (actions, 0..) |action, ai| {
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ pub const Action = enum {
|
||||||
sort_col_next,
|
sort_col_next,
|
||||||
sort_col_prev,
|
sort_col_prev,
|
||||||
sort_reverse,
|
sort_reverse,
|
||||||
account_filter,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const KeyCombo = struct {
|
pub const KeyCombo = struct {
|
||||||
|
|
@ -125,7 +124,6 @@ const default_bindings = [_]Binding{
|
||||||
.{ .action = .sort_col_next, .key = .{ .codepoint = '>' } },
|
.{ .action = .sort_col_next, .key = .{ .codepoint = '>' } },
|
||||||
.{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } },
|
.{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } },
|
||||||
.{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },
|
.{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },
|
||||||
.{ .action = .account_filter, .key = .{ .codepoint = 'a' } },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn defaults() KeyMap {
|
pub fn defaults() KeyMap {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ const std = @import("std");
|
||||||
const vaxis = @import("vaxis");
|
const vaxis = @import("vaxis");
|
||||||
const zfin = @import("../root.zig");
|
const zfin = @import("../root.zig");
|
||||||
const fmt = @import("../format.zig");
|
const fmt = @import("../format.zig");
|
||||||
const views = @import("../views/portfolio_sections.zig");
|
|
||||||
const cli = @import("../commands/common.zig");
|
const cli = @import("../commands/common.zig");
|
||||||
const theme_mod = @import("theme.zig");
|
const theme_mod = @import("theme.zig");
|
||||||
const tui = @import("../tui.zig");
|
const tui = @import("../tui.zig");
|
||||||
|
|
@ -33,16 +32,6 @@ pub const col_end_date: usize = col_end_weight + 14;
|
||||||
// Gain/loss column start position (used for alt-style coloring)
|
// Gain/loss column start position (used for alt-style coloring)
|
||||||
const gl_col_start: usize = col_end_market_value;
|
const gl_col_start: usize = col_end_market_value;
|
||||||
|
|
||||||
/// Map a semantic StyleIntent to a platform-specific vaxis style.
|
|
||||||
fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style {
|
|
||||||
return switch (intent) {
|
|
||||||
.normal => th.contentStyle(),
|
|
||||||
.muted => th.mutedStyle(),
|
|
||||||
.positive => th.positiveStyle(),
|
|
||||||
.negative => th.negativeStyle(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Data loading ──────────────────────────────────────────────
|
// ── Data loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// Load portfolio data: prices, summary, candle map, and historical snapshots.
|
/// Load portfolio data: prices, summary, candle map, and historical snapshots.
|
||||||
|
|
@ -198,7 +187,6 @@ pub fn loadPortfolioData(app: *App) void {
|
||||||
}
|
}
|
||||||
|
|
||||||
sortPortfolioAllocations(app);
|
sortPortfolioAllocations(app);
|
||||||
buildAccountList(app);
|
|
||||||
rebuildPortfolioRows(app);
|
rebuildPortfolioRows(app);
|
||||||
|
|
||||||
const summary = pf_data.summary;
|
const summary = pf_data.summary;
|
||||||
|
|
@ -279,20 +267,14 @@ pub fn sortPortfolioAllocations(app: *App) void {
|
||||||
|
|
||||||
pub fn rebuildPortfolioRows(app: *App) void {
|
pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
app.portfolio_rows.clearRetainingCapacity();
|
app.portfolio_rows.clearRetainingCapacity();
|
||||||
app.freePreparedSections();
|
|
||||||
|
|
||||||
if (app.portfolio_summary) |s| {
|
if (app.portfolio_summary) |s| {
|
||||||
for (s.allocations, 0..) |a, i| {
|
for (s.allocations, 0..) |a, i| {
|
||||||
// Skip allocations that don't match account filter
|
// Count lots for this symbol
|
||||||
if (!allocationMatchesFilter(app, a)) continue;
|
|
||||||
|
|
||||||
// Count lots for this symbol (filtered by account when filter is active)
|
|
||||||
var lcount: usize = 0;
|
var lcount: usize = 0;
|
||||||
if (app.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)) lcount += 1;
|
||||||
if (matchesAccountFilter(app, lot.account)) lcount += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,7 +293,6 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
defer matching.deinit(app.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)) {
|
||||||
if (matchesAccountFilter(app, lot.account))
|
|
||||||
matching.append(app.allocator, lot) catch continue;
|
matching.append(app.allocator, lot) catch continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -386,8 +367,6 @@ pub fn rebuildPortfolioRows(app: *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.
|
||||||
// Hide watchlist entirely when account filter is active (watchlist items don't belong to accounts).
|
|
||||||
if (app.account_filter == null) {
|
|
||||||
var watch_seen = std.StringHashMap(void).init(app.allocator);
|
var watch_seen = std.StringHashMap(void).init(app.allocator);
|
||||||
defer watch_seen.deinit();
|
defer watch_seen.deinit();
|
||||||
|
|
||||||
|
|
@ -423,85 +402,60 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
}) catch continue;
|
}) catch continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Options section (sorted by expiration date, then symbol; filtered by account)
|
// Options section
|
||||||
if (app.portfolio) |pf| {
|
if (app.portfolio) |pf| {
|
||||||
app.prepared_options = views.Options.init(app.allocator, pf.lots, app.account_filter) catch null;
|
if (pf.hasType(.option)) {
|
||||||
if (app.prepared_options) |opts| {
|
|
||||||
if (opts.items.len > 0) {
|
|
||||||
app.portfolio_rows.append(app.allocator, .{
|
app.portfolio_rows.append(app.allocator, .{
|
||||||
.kind = .section_header,
|
.kind = .section_header,
|
||||||
.symbol = "Options",
|
.symbol = "Options",
|
||||||
}) catch {};
|
}) catch {};
|
||||||
for (opts.items) |po| {
|
for (pf.lots) |lot| {
|
||||||
|
if (lot.security_type == .option) {
|
||||||
app.portfolio_rows.append(app.allocator, .{
|
app.portfolio_rows.append(app.allocator, .{
|
||||||
.kind = .option_row,
|
.kind = .option_row,
|
||||||
.symbol = po.lot.symbol,
|
.symbol = lot.symbol,
|
||||||
.lot = po.lot,
|
|
||||||
.prepared_text = po.columns[0].text,
|
|
||||||
.row_style = po.row_style,
|
|
||||||
.premium_style = po.premium_style,
|
|
||||||
.premium_col_start = po.premium_col_start,
|
|
||||||
}) catch continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CDs section (sorted by maturity date, earliest first; filtered by account)
|
|
||||||
app.prepared_cds = views.CDs.init(app.allocator, pf.lots, app.account_filter) catch null;
|
|
||||||
if (app.prepared_cds) |cds| {
|
|
||||||
if (cds.items.len > 0) {
|
|
||||||
app.portfolio_rows.append(app.allocator, .{
|
|
||||||
.kind = .section_header,
|
|
||||||
.symbol = "Certificates of Deposit",
|
|
||||||
}) catch {};
|
|
||||||
for (cds.items) |pc| {
|
|
||||||
app.portfolio_rows.append(app.allocator, .{
|
|
||||||
.kind = .cd_row,
|
|
||||||
.symbol = pc.lot.symbol,
|
|
||||||
.lot = pc.lot,
|
|
||||||
.prepared_text = pc.text,
|
|
||||||
.row_style = pc.row_style,
|
|
||||||
}) catch continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cash section (filtered by account when filter is active)
|
|
||||||
if (pf.hasType(.cash)) {
|
|
||||||
// When filtered, only show cash lots matching the account
|
|
||||||
if (app.account_filter != null) {
|
|
||||||
var cash_lots: std.ArrayList(zfin.Lot) = .empty;
|
|
||||||
defer cash_lots.deinit(app.allocator);
|
|
||||||
for (pf.lots) |lot| {
|
|
||||||
if (lot.security_type == .cash and matchesAccountFilter(app, lot.account)) {
|
|
||||||
cash_lots.append(app.allocator, lot) catch continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cash_lots.items.len > 0) {
|
|
||||||
app.portfolio_rows.append(app.allocator, .{
|
|
||||||
.kind = .section_header,
|
|
||||||
.symbol = "Cash",
|
|
||||||
}) catch {};
|
|
||||||
for (cash_lots.items) |lot| {
|
|
||||||
app.portfolio_rows.append(app.allocator, .{
|
|
||||||
.kind = .cash_row,
|
|
||||||
.symbol = lot.account orelse "Unknown",
|
|
||||||
.lot = lot,
|
.lot = lot,
|
||||||
}) catch continue;
|
}) catch continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Unfiltered: show total + expandable per-account rows
|
|
||||||
|
// CDs section (sorted by maturity date, earliest first)
|
||||||
|
if (pf.hasType(.cd)) {
|
||||||
|
app.portfolio_rows.append(app.allocator, .{
|
||||||
|
.kind = .section_header,
|
||||||
|
.symbol = "Certificates of Deposit",
|
||||||
|
}) catch {};
|
||||||
|
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
|
||||||
|
defer cd_lots.deinit(app.allocator);
|
||||||
|
for (pf.lots) |lot| {
|
||||||
|
if (lot.security_type == .cd) {
|
||||||
|
cd_lots.append(app.allocator, lot) catch continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
|
||||||
|
for (cd_lots.items) |lot| {
|
||||||
|
app.portfolio_rows.append(app.allocator, .{
|
||||||
|
.kind = .cd_row,
|
||||||
|
.symbol = lot.symbol,
|
||||||
|
.lot = lot,
|
||||||
|
}) catch continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cash section (single total row, expandable to show per-account)
|
||||||
|
if (pf.hasType(.cash)) {
|
||||||
app.portfolio_rows.append(app.allocator, .{
|
app.portfolio_rows.append(app.allocator, .{
|
||||||
.kind = .section_header,
|
.kind = .section_header,
|
||||||
.symbol = "Cash",
|
.symbol = "Cash",
|
||||||
}) catch {};
|
}) catch {};
|
||||||
|
// Total cash row
|
||||||
app.portfolio_rows.append(app.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)
|
||||||
if (app.cash_expanded) {
|
if (app.cash_expanded) {
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (lot.security_type == .cash) {
|
if (lot.security_type == .cash) {
|
||||||
|
|
@ -514,19 +468,19 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Illiquid assets section (hidden when account filter is active)
|
// Illiquid assets section (similar to cash: total row, expandable)
|
||||||
if (app.account_filter == null) {
|
|
||||||
if (pf.hasType(.illiquid)) {
|
if (pf.hasType(.illiquid)) {
|
||||||
app.portfolio_rows.append(app.allocator, .{
|
app.portfolio_rows.append(app.allocator, .{
|
||||||
.kind = .section_header,
|
.kind = .section_header,
|
||||||
.symbol = "Illiquid Assets",
|
.symbol = "Illiquid Assets",
|
||||||
}) catch {};
|
}) catch {};
|
||||||
|
// Total illiquid row
|
||||||
app.portfolio_rows.append(app.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)
|
||||||
if (app.illiquid_expanded) {
|
if (app.illiquid_expanded) {
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (lot.security_type == .illiquid) {
|
if (lot.security_type == .illiquid) {
|
||||||
|
|
@ -540,170 +494,6 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the sorted list of distinct account names from portfolio lots.
|
|
||||||
/// Called after portfolio data is loaded or reloaded.
|
|
||||||
pub fn buildAccountList(app: *App) void {
|
|
||||||
app.account_list.clearRetainingCapacity();
|
|
||||||
|
|
||||||
const pf = app.portfolio orelse return;
|
|
||||||
|
|
||||||
// Use a set to deduplicate
|
|
||||||
var seen = std.StringHashMap(void).init(app.allocator);
|
|
||||||
defer seen.deinit();
|
|
||||||
|
|
||||||
for (pf.lots) |lot| {
|
|
||||||
if (lot.account) |acct| {
|
|
||||||
if (acct.len > 0 and !seen.contains(acct)) {
|
|
||||||
seen.put(acct, {}) catch continue;
|
|
||||||
app.account_list.append(app.allocator, acct) catch continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort alphabetically
|
|
||||||
std.mem.sort([]const u8, app.account_list.items, {}, struct {
|
|
||||||
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
|
|
||||||
return std.mem.lessThan(u8, a, b);
|
|
||||||
}
|
|
||||||
}.lessThan);
|
|
||||||
|
|
||||||
// If the current filter no longer exists in the new list, clear it
|
|
||||||
if (app.account_filter) |af| {
|
|
||||||
var found = false;
|
|
||||||
for (app.account_list.items) |acct| {
|
|
||||||
if (std.mem.eql(u8, acct, af)) {
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) app.setAccountFilter(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a lot matches the active account filter.
|
|
||||||
/// Returns true if no filter is active or the lot's account matches.
|
|
||||||
fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool {
|
|
||||||
const filter = app.account_filter orelse return true;
|
|
||||||
const acct = account orelse return false;
|
|
||||||
return std.mem.eql(u8, acct, filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if an allocation matches the active account filter.
|
|
||||||
/// Uses the allocation's account field (which is "Multiple" for mixed-account positions).
|
|
||||||
/// For "Multiple" accounts, we need to check if any lot with this symbol belongs to the filtered account.
|
|
||||||
fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool {
|
|
||||||
const filter = app.account_filter orelse return true;
|
|
||||||
// Simple case: allocation has a single account
|
|
||||||
if (!std.mem.eql(u8, a.account, "Multiple")) {
|
|
||||||
return std.mem.eql(u8, a.account, filter);
|
|
||||||
}
|
|
||||||
// "Multiple" account: check if any stock lot for this symbol belongs to the filtered account
|
|
||||||
if (app.portfolio) |pf| {
|
|
||||||
for (pf.lots) |lot| {
|
|
||||||
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
|
||||||
if (lot.account) |la| {
|
|
||||||
if (std.mem.eql(u8, la, filter)) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Account-filtered view of an allocation. When a position spans multiple accounts,
|
|
||||||
/// this holds the values for only the lots matching the active account filter.
|
|
||||||
const FilteredAlloc = struct {
|
|
||||||
shares: f64,
|
|
||||||
cost_basis: f64,
|
|
||||||
market_value: f64,
|
|
||||||
unrealized_gain_loss: f64,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Compute account-filtered values for an allocation.
|
|
||||||
/// For single-account positions (or no filter), returns the allocation's own values.
|
|
||||||
/// For "Multiple"-account positions with a filter, sums only the matching lots.
|
|
||||||
fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc {
|
|
||||||
const filter = app.account_filter orelse return .{
|
|
||||||
.shares = a.shares,
|
|
||||||
.cost_basis = a.cost_basis,
|
|
||||||
.market_value = a.market_value,
|
|
||||||
.unrealized_gain_loss = a.unrealized_gain_loss,
|
|
||||||
};
|
|
||||||
if (!std.mem.eql(u8, a.account, "Multiple")) return .{
|
|
||||||
.shares = a.shares,
|
|
||||||
.cost_basis = a.cost_basis,
|
|
||||||
.market_value = a.market_value,
|
|
||||||
.unrealized_gain_loss = a.unrealized_gain_loss,
|
|
||||||
};
|
|
||||||
// Sum values from only the lots matching the filter
|
|
||||||
var shares: f64 = 0;
|
|
||||||
var cost: f64 = 0;
|
|
||||||
if (app.portfolio) |pf| {
|
|
||||||
for (pf.lots) |lot| {
|
|
||||||
if (lot.security_type != .stock) continue;
|
|
||||||
if (!std.mem.eql(u8, lot.priceSymbol(), a.symbol)) continue;
|
|
||||||
if (!lot.isOpen()) continue;
|
|
||||||
const la = lot.account orelse "";
|
|
||||||
if (!std.mem.eql(u8, la, filter)) continue;
|
|
||||||
shares += lot.shares;
|
|
||||||
cost += lot.shares * lot.open_price;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mv = shares * a.current_price * a.price_ratio;
|
|
||||||
return .{
|
|
||||||
.shares = shares,
|
|
||||||
.cost_basis = cost,
|
|
||||||
.market_value = mv,
|
|
||||||
.unrealized_gain_loss = mv - cost,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Totals for the filtered account view (stocks + cash + CDs + options).
|
|
||||||
const FilteredTotals = struct {
|
|
||||||
value: f64,
|
|
||||||
cost: f64,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Compute total value and cost across all asset types for the active account filter.
|
|
||||||
/// Returns {0, 0} if no filter is active.
|
|
||||||
fn computeFilteredTotals(app: *const App) FilteredTotals {
|
|
||||||
if (app.account_filter == null) return .{ .value = 0, .cost = 0 };
|
|
||||||
var value: f64 = 0;
|
|
||||||
var cost: f64 = 0;
|
|
||||||
if (app.portfolio_summary) |s| {
|
|
||||||
for (s.allocations) |a| {
|
|
||||||
if (allocationMatchesFilter(app, a)) {
|
|
||||||
const fa = filteredAllocValues(app, a);
|
|
||||||
value += fa.market_value;
|
|
||||||
cost += fa.cost_basis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (app.portfolio) |pf| {
|
|
||||||
for (pf.lots) |lot| {
|
|
||||||
if (!matchesAccountFilter(app, lot.account)) continue;
|
|
||||||
switch (lot.security_type) {
|
|
||||||
.cash => {
|
|
||||||
value += lot.shares;
|
|
||||||
cost += lot.shares;
|
|
||||||
},
|
|
||||||
.cd => {
|
|
||||||
value += lot.shares;
|
|
||||||
cost += lot.shares;
|
|
||||||
},
|
|
||||||
.option => {
|
|
||||||
const opt_cost = @abs(lot.shares) * lot.open_price;
|
|
||||||
value += opt_cost;
|
|
||||||
cost += opt_cost;
|
|
||||||
},
|
|
||||||
else => {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return .{ .value = value, .cost = cost };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rendering ─────────────────────────────────────────────────
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
@ -720,39 +510,6 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
if (app.portfolio_summary) |s| {
|
if (app.portfolio_summary) |s| {
|
||||||
if (app.account_filter) |af| {
|
|
||||||
// Filtered mode: compute account-specific totals
|
|
||||||
const ft = computeFilteredTotals(app);
|
|
||||||
const filtered_value = ft.value;
|
|
||||||
const filtered_cost = ft.cost;
|
|
||||||
const filtered_gl = filtered_value - filtered_cost;
|
|
||||||
const filtered_return = if (filtered_cost > 0) (filtered_gl / filtered_cost) else @as(f64, 0);
|
|
||||||
|
|
||||||
// Account name line
|
|
||||||
const acct_text = try std.fmt.allocPrint(arena, " Account: {s}", .{af});
|
|
||||||
try lines.append(arena, .{ .text = acct_text, .style = th.headerStyle() });
|
|
||||||
|
|
||||||
var val_buf: [24]u8 = undefined;
|
|
||||||
var cost_buf: [24]u8 = undefined;
|
|
||||||
var gl_buf: [24]u8 = undefined;
|
|
||||||
const val_str = fmt.fmtMoneyAbs(&val_buf, filtered_value);
|
|
||||||
const cost_str = fmt.fmtMoneyAbs(&cost_buf, filtered_cost);
|
|
||||||
const gl_abs = if (filtered_gl >= 0) filtered_gl else -filtered_gl;
|
|
||||||
const gl_str = fmt.fmtMoneyAbs(&gl_buf, gl_abs);
|
|
||||||
const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{
|
|
||||||
val_str, cost_str, if (filtered_gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, filtered_return * 100.0,
|
|
||||||
});
|
|
||||||
const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle();
|
|
||||||
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
|
|
||||||
|
|
||||||
if (app.candle_last_date) |d| {
|
|
||||||
var asof_buf: [10]u8 = undefined;
|
|
||||||
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {s})", .{d.format(&asof_buf)});
|
|
||||||
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
|
|
||||||
}
|
|
||||||
// No historical snapshots or net worth when filtered
|
|
||||||
} else {
|
|
||||||
// Unfiltered mode: use portfolio_summary totals directly
|
|
||||||
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;
|
||||||
|
|
@ -792,6 +549,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
|
||||||
// Historical portfolio value snapshots
|
// Historical portfolio value snapshots
|
||||||
if (app.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: --"
|
||||||
var hist_parts: [6][]const u8 = undefined;
|
var hist_parts: [6][]const u8 = undefined;
|
||||||
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
|
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
|
||||||
const snap = snapshots[pi];
|
const snap = snapshots[pi];
|
||||||
|
|
@ -804,7 +562,6 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
|
||||||
});
|
});
|
||||||
try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() });
|
try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else if (app.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 {
|
||||||
|
|
@ -845,12 +602,6 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
|
||||||
app.portfolio_header_lines = lines.items.len;
|
app.portfolio_header_lines = lines.items.len;
|
||||||
app.portfolio_line_count = 0;
|
app.portfolio_line_count = 0;
|
||||||
|
|
||||||
// Compute filtered total value for account-relative weight calculation
|
|
||||||
const filtered_total_for_weight: f64 = if (app.account_filter != null)
|
|
||||||
computeFilteredTotals(app).value
|
|
||||||
else
|
|
||||||
0;
|
|
||||||
|
|
||||||
// Data rows
|
// Data rows
|
||||||
for (app.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;
|
||||||
|
|
@ -861,30 +612,23 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
|
||||||
if (app.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];
|
||||||
// Use account-filtered values for multi-account positions
|
|
||||||
const fa = filteredAllocValues(app, a);
|
|
||||||
const display_shares = fa.shares;
|
|
||||||
const display_avg_cost = if (fa.shares > 0) fa.cost_basis / fa.shares else a.avg_cost;
|
|
||||||
const display_mv = fa.market_value;
|
|
||||||
const display_gl = fa.unrealized_gain_loss;
|
|
||||||
|
|
||||||
const is_multi = row.lot_count > 1;
|
const is_multi = row.lot_count > 1;
|
||||||
const is_expanded = is_multi and row.pos_idx < app.expanded.len and app.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 (fa.cost_basis > 0) (display_gl / fa.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);
|
||||||
var gl_val_buf: [24]u8 = undefined;
|
var gl_val_buf: [24]u8 = undefined;
|
||||||
const gl_abs = if (display_gl >= 0) display_gl else -display_gl;
|
const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss;
|
||||||
const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs);
|
const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs);
|
||||||
var pnl_buf: [20]u8 = undefined;
|
var pnl_buf: [20]u8 = undefined;
|
||||||
const pnl_str = if (display_gl >= 0)
|
const pnl_str = if (a.unrealized_gain_loss >= 0)
|
||||||
std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?"
|
std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?"
|
||||||
else
|
else
|
||||||
std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?";
|
std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?";
|
||||||
var mv_buf: [24]u8 = undefined;
|
var mv_buf: [24]u8 = undefined;
|
||||||
const mv_str = fmt.fmtMoneyAbs(&mv_buf, display_mv);
|
const mv_str = fmt.fmtMoneyAbs(&mv_buf, a.market_value);
|
||||||
var cost_buf2: [24]u8 = undefined;
|
var cost_buf2: [24]u8 = undefined;
|
||||||
const cost_str = fmt.fmtMoneyAbs(&cost_buf2, display_avg_cost);
|
const cost_str = fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost);
|
||||||
var price_buf2: [24]u8 = undefined;
|
var price_buf2: [24]u8 = undefined;
|
||||||
const price_str = fmt.fmtMoneyAbs(&price_buf2, a.current_price);
|
const price_str = fmt.fmtMoneyAbs(&price_buf2, a.current_price);
|
||||||
|
|
||||||
|
|
@ -930,13 +674,8 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0)
|
|
||||||
(display_mv / filtered_total_for_weight)
|
|
||||||
else
|
|
||||||
a.weight;
|
|
||||||
|
|
||||||
const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{
|
const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{
|
||||||
arrow, star, a.display_symbol, display_shares, cost_str, price_str, mv_str, pnl_str, display_weight * 100.0, date_col, acct_col,
|
arrow, star, a.display_symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, acct_col,
|
||||||
});
|
});
|
||||||
|
|
||||||
// base: neutral text for main cols, green/red only for gain/loss col
|
// base: neutral text for main cols, green/red only for gain/loss col
|
||||||
|
|
@ -1020,29 +759,61 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
|
||||||
try lines.append(arena, .{ .text = hdr_text, .style = hdr_style });
|
try lines.append(arena, .{ .text = hdr_text, .style = hdr_style });
|
||||||
// Add column headers for each section type
|
// Add column headers for each section type
|
||||||
if (std.mem.eql(u8, row.symbol, "Options")) {
|
if (std.mem.eql(u8, row.symbol, "Options")) {
|
||||||
const col_hdr = try std.fmt.allocPrint(arena, views.OptionsLayout.header, views.OptionsLayout.header_labels);
|
const col_hdr = try std.fmt.allocPrint(arena, " {s:<30} {s:>6} {s:>12} {s:>14} {s}", .{
|
||||||
|
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
|
||||||
|
});
|
||||||
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
|
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
|
||||||
} else if (std.mem.eql(u8, row.symbol, "Certificates of Deposit")) {
|
} else if (std.mem.eql(u8, row.symbol, "Certificates of Deposit")) {
|
||||||
const col_hdr = try std.fmt.allocPrint(arena, views.CDsLayout.header, views.CDsLayout.header_labels);
|
const col_hdr = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
|
||||||
|
"CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account",
|
||||||
|
});
|
||||||
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
|
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.option_row => {
|
.option_row => {
|
||||||
if (row.prepared_text) |text| {
|
if (row.lot) |lot| {
|
||||||
const row_style2 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
|
// Options: symbol (description), qty (contracts), cost/contract, cost basis, account
|
||||||
const prem_style = if (is_cursor) th.selectStyle() else mapIntent(th, row.premium_style);
|
const qty = lot.shares; // negative = short
|
||||||
try lines.append(arena, .{
|
const cost_per = lot.open_price; // per-contract cost
|
||||||
.text = text,
|
const total_cost = @abs(qty) * cost_per;
|
||||||
.style = row_style2,
|
var cost_buf3: [24]u8 = undefined;
|
||||||
.alt_style = prem_style,
|
var total_buf: [24]u8 = undefined;
|
||||||
.alt_start = row.premium_col_start,
|
const acct_col2: []const u8 = lot.account orelse "";
|
||||||
.alt_end = row.premium_col_start + 14,
|
const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{
|
||||||
|
lot.symbol,
|
||||||
|
qty,
|
||||||
|
fmt.fmtMoneyAbs(&cost_buf3, cost_per),
|
||||||
|
fmt.fmtMoneyAbs(&total_buf, total_cost),
|
||||||
|
acct_col2,
|
||||||
});
|
});
|
||||||
|
const row_style2 = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||||
|
try lines.append(arena, .{ .text = text, .style = row_style2 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.cd_row => {
|
.cd_row => {
|
||||||
if (row.prepared_text) |text| {
|
if (row.lot) |lot| {
|
||||||
const row_style3 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
|
// CDs: symbol (CUSIP), face value, rate%, maturity date, note, account
|
||||||
|
var face_buf: [24]u8 = undefined;
|
||||||
|
var mat_buf: [10]u8 = undefined;
|
||||||
|
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
|
||||||
|
var rate_str_buf: [10]u8 = undefined;
|
||||||
|
const rate_str: []const u8 = if (lot.rate) |r|
|
||||||
|
std.fmt.bufPrint(&rate_str_buf, "{d:.2}%", .{r}) catch "--"
|
||||||
|
else
|
||||||
|
"--";
|
||||||
|
const note_str: []const u8 = lot.note orelse "";
|
||||||
|
// Truncate note to 40 chars for display
|
||||||
|
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
|
||||||
|
const acct_col3: []const u8 = lot.account orelse "";
|
||||||
|
const text = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
|
||||||
|
lot.symbol,
|
||||||
|
fmt.fmtMoneyAbs(&face_buf, lot.shares),
|
||||||
|
rate_str,
|
||||||
|
mat_str,
|
||||||
|
note_display,
|
||||||
|
acct_col3,
|
||||||
|
});
|
||||||
|
const row_style3 = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||||
try lines.append(arena, .{ .text = text, .style = row_style3 });
|
try lines.append(arena, .{ .text = text, .style = row_style3 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1154,11 +925,6 @@ fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wid
|
||||||
/// 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(app: *App) void {
|
pub fn reloadPortfolioFile(app: *App) void {
|
||||||
// Save the account filter name before freeing the old portfolio.
|
|
||||||
// account_filter is an owned copy so it survives the portfolio free,
|
|
||||||
// but account_list entries borrow from the portfolio and will dangle.
|
|
||||||
app.account_list.clearRetainingCapacity();
|
|
||||||
|
|
||||||
// Re-read the portfolio file
|
// Re-read the portfolio file
|
||||||
if (app.portfolio) |*pf| pf.deinit();
|
if (app.portfolio) |*pf| pf.deinit();
|
||||||
app.portfolio = null;
|
app.portfolio = null;
|
||||||
|
|
@ -1253,7 +1019,6 @@ pub fn reloadPortfolioFile(app: *App) void {
|
||||||
}
|
}
|
||||||
|
|
||||||
sortPortfolioAllocations(app);
|
sortPortfolioAllocations(app);
|
||||||
buildAccountList(app);
|
|
||||||
rebuildPortfolioRows(app);
|
rebuildPortfolioRows(app);
|
||||||
|
|
||||||
// Invalidate analysis data -- it holds pointers into old portfolio memory
|
// Invalidate analysis data -- it holds pointers into old portfolio memory
|
||||||
|
|
@ -1276,42 +1041,3 @@ pub fn reloadPortfolioFile(app: *App) void {
|
||||||
app.setStatus("Portfolio reloaded from disk");
|
app.setStatus("Portfolio reloaded from disk");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Account picker ────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Number of header lines in the account picker before the list items start.
|
|
||||||
/// Used for mouse click hit-testing.
|
|
||||||
pub const account_picker_header_lines: usize = 3;
|
|
||||||
|
|
||||||
/// Draw the account picker overlay (replaces portfolio content).
|
|
||||||
pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
|
||||||
const th = app.theme;
|
|
||||||
var lines: std.ArrayList(tui.StyledLine) = .empty;
|
|
||||||
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
||||||
try lines.append(arena, .{ .text = " Filter by Account", .style = th.headerStyle() });
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
||||||
|
|
||||||
// Item 0 = "All accounts" (clears filter)
|
|
||||||
const total_items = app.account_list.items.len + 1;
|
|
||||||
for (0..total_items) |i| {
|
|
||||||
const is_selected = i == app.account_picker_cursor;
|
|
||||||
const marker: []const u8 = if (is_selected) " > " else " ";
|
|
||||||
const label: []const u8 = if (i == 0) "All accounts" else app.account_list.items[i - 1];
|
|
||||||
const text = try std.fmt.allocPrint(arena, "{s}{s}", .{ marker, label });
|
|
||||||
const style = if (is_selected) th.selectStyle() else th.contentStyle();
|
|
||||||
try lines.append(arena, .{ .text = text, .style = style });
|
|
||||||
}
|
|
||||||
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
||||||
|
|
||||||
// Scroll so cursor is visible
|
|
||||||
const cursor_line = app.account_picker_cursor + account_picker_header_lines;
|
|
||||||
var start: usize = 0;
|
|
||||||
if (cursor_line >= height) {
|
|
||||||
start = cursor_line - height + 2; // keep one line of padding below
|
|
||||||
}
|
|
||||||
start = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0);
|
|
||||||
|
|
||||||
try app.drawStyledContent(arena, buf, width, height, lines.items[start..]);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
//! View models for portfolio sections (Options, CDs).
|
|
||||||
//! Produces renderer-agnostic display data consumed by both CLI and TUI.
|
|
||||||
//! Column widths, format strings, computed values, and style decisions
|
|
||||||
//! are defined here. Renderers are thin adapters that map StyleIntent
|
|
||||||
//! to platform-specific styles and emit pre-formatted text.
|
|
||||||
|
|
||||||
const std = @import("std");
|
|
||||||
const Lot = @import("../models/portfolio.zig").Lot;
|
|
||||||
const Date = @import("../models/date.zig").Date;
|
|
||||||
const fmt = @import("../format.zig");
|
|
||||||
|
|
||||||
// ── Options ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Column layout for the Options section.
|
|
||||||
/// All format strings are derived from the width constants.
|
|
||||||
pub const OptionsLayout = struct {
|
|
||||||
const cp = std.fmt.comptimePrint;
|
|
||||||
pub const prefix = " ";
|
|
||||||
pub const symbol_w = 30;
|
|
||||||
pub const qty_w = 6;
|
|
||||||
pub const cost_w = 12;
|
|
||||||
pub const premium_w = 14;
|
|
||||||
pub const account_w = 10;
|
|
||||||
|
|
||||||
pub const premium_col_start: usize = prefix.len + symbol_w + 1 + qty_w + 1 + cost_w + 1;
|
|
||||||
|
|
||||||
pub const header = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:>{d}}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}";
|
|
||||||
pub const header_labels = .{ "Contract", "Qty", "Cost/Ctrct", "Premium", "Account" };
|
|
||||||
|
|
||||||
pub const separator = prefix ++ cp("{{s:->{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:->{d}}}", .{qty_w}) ++ " " ++ cp("{{s:->{d}}}", .{cost_w}) ++ " " ++ cp("{{s:->{d}}}", .{premium_w}) ++ " " ++ cp("{{s:->{d}}}", .{account_w});
|
|
||||||
pub const separator_fills = .{ "", "", "", "", "" };
|
|
||||||
|
|
||||||
pub const data_row = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{d:>{d}.0}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}";
|
|
||||||
};
|
|
||||||
|
|
||||||
/// A styled text span for multi-style row rendering.
|
|
||||||
pub const StyledSpan = struct {
|
|
||||||
text: []const u8,
|
|
||||||
style: fmt.StyleIntent,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// A single option row with pre-computed display values.
|
|
||||||
pub const Option = struct {
|
|
||||||
lot: Lot,
|
|
||||||
premium: f64,
|
|
||||||
received: bool,
|
|
||||||
is_expired: bool,
|
|
||||||
row_style: fmt.StyleIntent,
|
|
||||||
premium_style: fmt.StyleIntent,
|
|
||||||
columns: [2]StyledSpan,
|
|
||||||
premium_col_start: usize,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Collection of prepared option rows. Owns all allocated text.
|
|
||||||
pub const Options = struct {
|
|
||||||
items: []const Option,
|
|
||||||
allocator: std.mem.Allocator,
|
|
||||||
|
|
||||||
/// Build sorted, filtered, display-ready option rows from raw lots.
|
|
||||||
pub fn init(allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !Options {
|
|
||||||
const today = fmt.todayDate();
|
|
||||||
var list: std.ArrayList(Option) = .empty;
|
|
||||||
errdefer {
|
|
||||||
for (list.items) |opt| allocator.free(opt.columns[0].text);
|
|
||||||
list.deinit(allocator);
|
|
||||||
}
|
|
||||||
|
|
||||||
var tmp: std.ArrayList(Lot) = .empty;
|
|
||||||
defer tmp.deinit(allocator);
|
|
||||||
for (lots) |lot| {
|
|
||||||
if (lot.security_type != .option) continue;
|
|
||||||
if (account_filter) |af| {
|
|
||||||
const la = lot.account orelse "";
|
|
||||||
if (!std.mem.eql(u8, la, af)) continue;
|
|
||||||
}
|
|
||||||
try tmp.append(allocator, lot);
|
|
||||||
}
|
|
||||||
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn);
|
|
||||||
|
|
||||||
for (tmp.items) |lot| {
|
|
||||||
const qty = lot.shares;
|
|
||||||
const cost_per = lot.open_price;
|
|
||||||
const premium = @abs(qty) * cost_per * lot.multiplier;
|
|
||||||
const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false;
|
|
||||||
const received = qty < 0;
|
|
||||||
|
|
||||||
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
|
|
||||||
const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative;
|
|
||||||
|
|
||||||
var cost_buf: [24]u8 = undefined;
|
|
||||||
var prem_val_buf: [24]u8 = undefined;
|
|
||||||
const prem_money = fmt.fmtMoneyAbs(&prem_val_buf, premium);
|
|
||||||
var prem_buf: [20]u8 = undefined;
|
|
||||||
const prem_str = if (received)
|
|
||||||
std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
|
|
||||||
else
|
|
||||||
std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
|
|
||||||
const acct = lot.account orelse "";
|
|
||||||
|
|
||||||
const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{
|
|
||||||
lot.symbol,
|
|
||||||
qty,
|
|
||||||
fmt.fmtMoneyAbs(&cost_buf, cost_per),
|
|
||||||
prem_str,
|
|
||||||
acct,
|
|
||||||
});
|
|
||||||
|
|
||||||
try list.append(allocator, .{
|
|
||||||
.lot = lot,
|
|
||||||
.premium = premium,
|
|
||||||
.received = received,
|
|
||||||
.is_expired = is_expired,
|
|
||||||
.row_style = row_style,
|
|
||||||
.premium_style = premium_style,
|
|
||||||
.columns = .{
|
|
||||||
.{ .text = text, .style = row_style },
|
|
||||||
.{ .text = prem_str, .style = premium_style },
|
|
||||||
},
|
|
||||||
.premium_col_start = OptionsLayout.premium_col_start,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return .{ .items = try list.toOwnedSlice(allocator), .allocator = allocator };
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deinit(self: *Options) void {
|
|
||||||
for (self.items) |opt| self.allocator.free(opt.columns[0].text);
|
|
||||||
self.allocator.free(self.items);
|
|
||||||
self.items = &.{};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── CDs ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Column layout for the Certificates of Deposit section.
|
|
||||||
pub const CDsLayout = struct {
|
|
||||||
const cp = std.fmt.comptimePrint;
|
|
||||||
pub const prefix = " ";
|
|
||||||
pub const cusip_w = 12;
|
|
||||||
pub const face_w = 14;
|
|
||||||
pub const rate_w = 7;
|
|
||||||
pub const maturity_w = 10;
|
|
||||||
pub const desc_w = 40;
|
|
||||||
pub const account_w = 10;
|
|
||||||
|
|
||||||
pub const header = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ " {s} {s}";
|
|
||||||
pub const header_labels = .{ "CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account" };
|
|
||||||
|
|
||||||
pub const separator = prefix ++ cp("{{s:->{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:->{d}}}", .{face_w}) ++ " " ++ cp("{{s:->{d}}}", .{rate_w}) ++ " " ++ cp("{{s:->{d}}}", .{maturity_w}) ++ " " ++ cp("{{s:->{d}}}", .{desc_w}) ++ " " ++ cp("{{s:->{d}}}", .{account_w});
|
|
||||||
pub const separator_fills = .{ "", "", "", "", "", "" };
|
|
||||||
|
|
||||||
pub const data_row = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ " {s} {s}";
|
|
||||||
};
|
|
||||||
|
|
||||||
/// A single CD row with pre-computed display values.
|
|
||||||
pub const CD = struct {
|
|
||||||
lot: Lot,
|
|
||||||
is_expired: bool,
|
|
||||||
row_style: fmt.StyleIntent,
|
|
||||||
text: []const u8,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Collection of prepared CD rows. Owns all allocated text.
|
|
||||||
pub const CDs = struct {
|
|
||||||
items: []const CD,
|
|
||||||
allocator: std.mem.Allocator,
|
|
||||||
|
|
||||||
/// Build sorted, filtered, display-ready CD rows from raw lots.
|
|
||||||
pub fn init(allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !CDs {
|
|
||||||
const today = fmt.todayDate();
|
|
||||||
var list: std.ArrayList(CD) = .empty;
|
|
||||||
errdefer {
|
|
||||||
for (list.items) |cd| allocator.free(cd.text);
|
|
||||||
list.deinit(allocator);
|
|
||||||
}
|
|
||||||
|
|
||||||
var tmp: std.ArrayList(Lot) = .empty;
|
|
||||||
defer tmp.deinit(allocator);
|
|
||||||
for (lots) |lot| {
|
|
||||||
if (lot.security_type != .cd) continue;
|
|
||||||
if (account_filter) |af| {
|
|
||||||
const la = lot.account orelse "";
|
|
||||||
if (!std.mem.eql(u8, la, af)) continue;
|
|
||||||
}
|
|
||||||
try tmp.append(allocator, lot);
|
|
||||||
}
|
|
||||||
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn);
|
|
||||||
|
|
||||||
for (tmp.items) |lot| {
|
|
||||||
const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false;
|
|
||||||
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
|
|
||||||
|
|
||||||
var face_buf: [24]u8 = undefined;
|
|
||||||
var mat_buf: [10]u8 = undefined;
|
|
||||||
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
|
|
||||||
var rate_buf: [10]u8 = undefined;
|
|
||||||
const rate_str: []const u8 = if (lot.rate) |r|
|
|
||||||
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
|
|
||||||
else
|
|
||||||
"--";
|
|
||||||
const note_str: []const u8 = lot.note orelse "";
|
|
||||||
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
|
|
||||||
const acct = lot.account orelse "";
|
|
||||||
|
|
||||||
const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{
|
|
||||||
lot.symbol,
|
|
||||||
fmt.fmtMoneyAbs(&face_buf, lot.shares),
|
|
||||||
rate_str,
|
|
||||||
mat_str,
|
|
||||||
note_display,
|
|
||||||
acct,
|
|
||||||
});
|
|
||||||
|
|
||||||
try list.append(allocator, .{
|
|
||||||
.lot = lot,
|
|
||||||
.is_expired = is_expired,
|
|
||||||
.row_style = row_style,
|
|
||||||
.text = text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return .{ .items = try list.toOwnedSlice(allocator), .allocator = allocator };
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deinit(self: *CDs) void {
|
|
||||||
for (self.items) |cd| self.allocator.free(cd.text);
|
|
||||||
self.allocator.free(self.items);
|
|
||||||
self.items = &.{};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Loading…
Add table
Reference in a new issue