ai: portfolio sorting
This commit is contained in:
parent
635e0931f9
commit
bbb11c29e1
6 changed files with 371 additions and 35 deletions
|
|
@ -108,6 +108,8 @@ pub const Allocation = struct {
|
|||
unrealized_return: f64,
|
||||
/// True if current_price came from a manual override rather than live API data.
|
||||
is_manual_price: bool = false,
|
||||
/// Account name (from lots; "Multiple" if lots span different accounts).
|
||||
account: []const u8 = "",
|
||||
};
|
||||
|
||||
/// Compute portfolio summary given positions and current prices.
|
||||
|
|
@ -145,6 +147,7 @@ pub fn portfolioSummary(
|
|||
.unrealized_pnl = mv - pos.total_cost,
|
||||
.unrealized_return = if (pos.total_cost > 0) (mv / pos.total_cost) - 1.0 else 0,
|
||||
.is_manual_price = if (manual_prices) |mp| mp.contains(pos.symbol) else false,
|
||||
.account = pos.account,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1160,6 +1160,13 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
|
|||
};
|
||||
defer summary.deinit(allocator);
|
||||
|
||||
// Sort allocations alphabetically by symbol
|
||||
std.mem.sort(zfin.risk.Allocation, summary.allocations, {}, struct {
|
||||
fn f(_: void, a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool {
|
||||
return std.mem.lessThan(u8, a.symbol, b.symbol);
|
||||
}
|
||||
}.f);
|
||||
|
||||
// Include non-stock assets in the grand total
|
||||
const cash_total = portfolio.totalCash();
|
||||
const cd_total = portfolio.totalCdFaceValue();
|
||||
|
|
@ -1218,10 +1225,10 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
|
|||
// Column headers
|
||||
try out.print("\n", .{});
|
||||
try setFg(out, color, CLR_MUTED);
|
||||
try out.print(" {s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{
|
||||
try out.print(" " ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{
|
||||
"Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account",
|
||||
});
|
||||
try out.print(" {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8} {s:->13} {s:->8}\n", .{
|
||||
try out.print(" " ++ std.fmt.comptimePrint("{{s:->{d}}}", .{fmt.sym_col_width}) ++ " {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8} {s:->13} {s:->8}\n", .{
|
||||
"", "", "", "", "", "", "", "", "",
|
||||
});
|
||||
try reset(out, color);
|
||||
|
|
@ -1261,7 +1268,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
|
|||
date_col_len = written.len;
|
||||
}
|
||||
|
||||
try out.print(" {s:<6} {d:>8.1} {s:>10} ", .{
|
||||
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{
|
||||
a.symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost),
|
||||
});
|
||||
if (a.is_manual_price) try setFg(out, color, CLR_YELLOW);
|
||||
|
|
@ -1515,28 +1522,26 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
|
|||
try out.print(" Cash\n", .{});
|
||||
try reset(out, color);
|
||||
try setFg(out, color, CLR_MUTED);
|
||||
try out.print(" {s:<20} {s:>14}\n", .{ "Account", "Balance" });
|
||||
try out.print(" {s:->20} {s:->14}\n", .{ "", "" });
|
||||
var cash_hdr_buf: [80]u8 = undefined;
|
||||
try out.print("{s}\n", .{fmt.fmtCashHeader(&cash_hdr_buf)});
|
||||
var cash_sep_buf: [80]u8 = undefined;
|
||||
try out.print("{s}\n", .{fmt.fmtCashSep(&cash_sep_buf)});
|
||||
try reset(out, color);
|
||||
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.lot_type != .cash) continue;
|
||||
var cash_buf: [24]u8 = undefined;
|
||||
const acct2: []const u8 = lot.account orelse "Unknown";
|
||||
try out.print(" {s:<20} {s:>14}\n", .{
|
||||
acct2,
|
||||
fmt.fmtMoney(&cash_buf, lot.shares),
|
||||
});
|
||||
var row_buf: [80]u8 = undefined;
|
||||
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares)});
|
||||
}
|
||||
// Cash total
|
||||
var sep_buf: [80]u8 = undefined;
|
||||
try setFg(out, color, CLR_MUTED);
|
||||
try out.print(" {s:->20} {s:->14}\n", .{ "", "" });
|
||||
try out.print("{s}\n", .{fmt.fmtCashSep(&sep_buf)});
|
||||
try reset(out, color);
|
||||
var cash_total_buf: [24]u8 = undefined;
|
||||
var total_buf: [80]u8 = undefined;
|
||||
try setBold(out, color);
|
||||
try out.print(" {s:>20} {s:>14}\n", .{
|
||||
"TOTAL", fmt.fmtMoney(&cash_total_buf, portfolio.totalCash()),
|
||||
});
|
||||
try out.print("{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash())});
|
||||
try reset(out, color);
|
||||
}
|
||||
|
||||
|
|
@ -1569,7 +1574,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
|
|||
ps2 = fmt.fmtMoney2(&price_str2, candles2[candles2.len - 1].close);
|
||||
}
|
||||
}
|
||||
try o.print(" {s:<6} {s:>10}\n", .{ sym, ps2 });
|
||||
try o.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps2 });
|
||||
}
|
||||
}.f;
|
||||
|
||||
|
|
@ -1667,7 +1672,7 @@ fn printCliLotRow(out: anytype, color: bool, lot: zfin.Lot, current_price: f64)
|
|||
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
|
||||
|
||||
try setFg(out, color, CLR_MUTED);
|
||||
try out.print(" {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{
|
||||
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{
|
||||
status_str, lot.shares, fmt.fmtMoney2(&lot_price_buf, lot.open_price), "", "",
|
||||
});
|
||||
try reset(out, color);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,103 @@ const Candle = @import("models/candle.zig").Candle;
|
|||
const OptionContract = @import("models/option.zig").OptionContract;
|
||||
const Lot = @import("models/portfolio.zig").Lot;
|
||||
|
||||
// ── Layout constants ─────────────────────────────────────────
|
||||
|
||||
/// Width of the symbol column in portfolio view (CLI + TUI).
|
||||
pub const sym_col_width = 7;
|
||||
/// Comptime format spec for left-aligned symbol column, e.g. "{s:<7}".
|
||||
pub const sym_col_spec = std.fmt.comptimePrint("{{s:<{d}}}", .{sym_col_width});
|
||||
|
||||
/// Width of the account name column in cash section (CLI + TUI).
|
||||
pub const cash_acct_width = 30;
|
||||
|
||||
/// Format the cash section column header: " Account Balance"
|
||||
pub fn fmtCashHeader(buf: []u8) []const u8 {
|
||||
const w = cash_acct_width;
|
||||
var pos: usize = 0;
|
||||
@memcpy(buf[0..2], " ");
|
||||
pos += 2;
|
||||
const acct_label = "Account";
|
||||
@memcpy(buf[pos..][0..acct_label.len], acct_label);
|
||||
@memset(buf[pos + acct_label.len ..][0 .. w - acct_label.len], ' ');
|
||||
pos += w;
|
||||
buf[pos] = ' ';
|
||||
pos += 1;
|
||||
const bal_label = "Balance";
|
||||
const bal_pad = if (bal_label.len < 14) 14 - bal_label.len else 0;
|
||||
@memset(buf[pos..][0..bal_pad], ' ');
|
||||
pos += bal_pad;
|
||||
@memcpy(buf[pos..][0..bal_label.len], bal_label);
|
||||
pos += bal_label.len;
|
||||
return buf[0..pos];
|
||||
}
|
||||
|
||||
/// Format a cash row: " account_name $1,234.56"
|
||||
/// Returns a slice of `buf`.
|
||||
pub fn fmtCashRow(buf: []u8, account: []const u8, amount: f64) []const u8 {
|
||||
var money_buf: [24]u8 = undefined;
|
||||
const money = fmtMoney(&money_buf, amount);
|
||||
const w = cash_acct_width;
|
||||
// " {name:<w} {money:>14}"
|
||||
const prefix = " ";
|
||||
var pos: usize = 0;
|
||||
@memcpy(buf[pos..][0..prefix.len], prefix);
|
||||
pos += prefix.len;
|
||||
const name_len = @min(account.len, w);
|
||||
@memcpy(buf[pos..][0..name_len], account[0..name_len]);
|
||||
if (name_len < w) @memset(buf[pos + name_len ..][0 .. w - name_len], ' ');
|
||||
pos += w;
|
||||
buf[pos] = ' ';
|
||||
pos += 1;
|
||||
// Right-align money in 14 chars
|
||||
const money_pad = if (money.len < 14) 14 - money.len else 0;
|
||||
@memset(buf[pos..][0..money_pad], ' ');
|
||||
pos += money_pad;
|
||||
@memcpy(buf[pos..][0..money.len], money);
|
||||
pos += money.len;
|
||||
return buf[0..pos];
|
||||
}
|
||||
|
||||
/// Format the cash total separator line.
|
||||
pub fn fmtCashSep(buf: []u8) []const u8 {
|
||||
const w = cash_acct_width;
|
||||
var pos: usize = 0;
|
||||
@memcpy(buf[0..2], " ");
|
||||
pos += 2;
|
||||
@memset(buf[pos..][0..w], '-');
|
||||
pos += w;
|
||||
buf[pos] = ' ';
|
||||
pos += 1;
|
||||
@memset(buf[pos..][0..14], '-');
|
||||
pos += 14;
|
||||
return buf[0..pos];
|
||||
}
|
||||
|
||||
/// Format the cash total row.
|
||||
pub fn fmtCashTotal(buf: []u8, total: f64) []const u8 {
|
||||
var money_buf: [24]u8 = undefined;
|
||||
const money = fmtMoney(&money_buf, total);
|
||||
const w = cash_acct_width;
|
||||
var pos: usize = 0;
|
||||
@memcpy(buf[0..2], " ");
|
||||
pos += 2;
|
||||
// Right-align "TOTAL" in w chars
|
||||
const label = "TOTAL";
|
||||
const label_pad = w - label.len;
|
||||
@memset(buf[pos..][0..label_pad], ' ');
|
||||
pos += label_pad;
|
||||
@memcpy(buf[pos..][0..label.len], label);
|
||||
pos += label.len;
|
||||
buf[pos] = ' ';
|
||||
pos += 1;
|
||||
const money_pad = if (money.len < 14) 14 - money.len else 0;
|
||||
@memset(buf[pos..][0..money_pad], ' ');
|
||||
pos += money_pad;
|
||||
@memcpy(buf[pos..][0..money.len], money);
|
||||
pos += money.len;
|
||||
return buf[0..pos];
|
||||
}
|
||||
|
||||
// ── Number formatters ────────────────────────────────────────
|
||||
|
||||
/// Format a dollar amount with commas and 2 decimals: $1,234.56
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ pub const Position = struct {
|
|||
closed_lots: u32,
|
||||
/// Total realized P&L from closed lots
|
||||
realized_pnl: f64,
|
||||
/// Account name (shared across lots, or "Multiple" if mixed).
|
||||
account: []const u8 = "",
|
||||
};
|
||||
|
||||
/// A portfolio is a collection of lots.
|
||||
|
|
@ -211,7 +213,15 @@ pub const Portfolio = struct {
|
|||
.open_lots = 0,
|
||||
.closed_lots = 0,
|
||||
.realized_pnl = 0,
|
||||
.account = lot.account orelse "",
|
||||
};
|
||||
} else {
|
||||
// Track account: if lots have different accounts, mark as "Multiple"
|
||||
const existing = entry.value_ptr.account;
|
||||
const new_acct = lot.account orelse "";
|
||||
if (existing.len > 0 and !std.mem.eql(u8, existing, "Multiple") and !std.mem.eql(u8, existing, new_acct)) {
|
||||
entry.value_ptr.account = "Multiple";
|
||||
}
|
||||
}
|
||||
if (lot.isOpen()) {
|
||||
entry.value_ptr.shares += lot.shares;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ pub const Action = enum {
|
|||
options_filter_9,
|
||||
chart_timeframe_next,
|
||||
chart_timeframe_prev,
|
||||
sort_col_next,
|
||||
sort_col_prev,
|
||||
sort_reverse,
|
||||
};
|
||||
|
||||
pub const KeyCombo = struct {
|
||||
|
|
@ -117,6 +120,9 @@ const default_bindings = [_]Binding{
|
|||
.{ .action = .options_filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } },
|
||||
.{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } },
|
||||
.{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } },
|
||||
.{ .action = .sort_col_next, .key = .{ .codepoint = '>' } },
|
||||
.{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } },
|
||||
.{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },
|
||||
};
|
||||
|
||||
pub fn defaults() KeyMap {
|
||||
|
|
|
|||
251
src/tui/main.zig
251
src/tui/main.zig
|
|
@ -17,6 +17,48 @@ const ascii_g = blk: {
|
|||
break :blk table;
|
||||
};
|
||||
|
||||
/// Build a fixed-display-width column header label with optional sort indicator.
|
||||
/// The indicator (▲/▼, 3 bytes, 1 display column) replaces a padding space so total
|
||||
/// display width stays constant. Indicator always appears on the left side.
|
||||
/// `left` controls text alignment (left-aligned vs right-aligned).
|
||||
fn colLabel(buf: []u8, name: []const u8, comptime col_width: usize, left: bool, indicator: ?[]const u8) []const u8 {
|
||||
const ind = indicator orelse {
|
||||
// No indicator: plain padded label
|
||||
if (left) {
|
||||
@memset(buf[0..col_width], ' ');
|
||||
@memcpy(buf[0..name.len], name);
|
||||
return buf[0..col_width];
|
||||
} else {
|
||||
@memset(buf[0..col_width], ' ');
|
||||
const offset = col_width - name.len;
|
||||
@memcpy(buf[offset..][0..name.len], name);
|
||||
return buf[0..col_width];
|
||||
}
|
||||
};
|
||||
// Indicator always on the left, replacing one padding space.
|
||||
// total display cols = col_width, byte length = col_width - 1 + ind.len
|
||||
const total_bytes = col_width - 1 + ind.len;
|
||||
if (total_bytes > buf.len) return name;
|
||||
if (left) {
|
||||
// "▲Name " — indicator, text, then spaces
|
||||
@memcpy(buf[0..ind.len], ind);
|
||||
@memcpy(buf[ind.len..][0..name.len], name);
|
||||
const content_len = ind.len + name.len;
|
||||
if (content_len < total_bytes) @memset(buf[content_len..total_bytes], ' ');
|
||||
} else {
|
||||
// " ▲Name" — spaces, indicator, then text
|
||||
const pad = col_width - name.len - 1;
|
||||
@memset(buf[0..pad], ' ');
|
||||
@memcpy(buf[pad..][0..ind.len], ind);
|
||||
@memcpy(buf[pad + ind.len ..][0..name.len], name);
|
||||
}
|
||||
return buf[0..total_bytes];
|
||||
}
|
||||
|
||||
// Portfolio column layout: gain/loss column start position (display columns).
|
||||
// prefix(4) + sym(sym_col_width+1) + shares(9) + avgcost(11) + price(11) + mv(17) = 4 + sym_col_width + 49
|
||||
const gl_col_start: usize = 4 + fmt.sym_col_width + 49;
|
||||
|
||||
fn glyph(ch: u8) []const u8 {
|
||||
if (ch < 128) return ascii_g[ch];
|
||||
return " ";
|
||||
|
|
@ -55,6 +97,57 @@ const InputMode = enum {
|
|||
help,
|
||||
};
|
||||
|
||||
/// Sort field for portfolio columns.
|
||||
const PortfolioSortField = enum {
|
||||
symbol,
|
||||
shares,
|
||||
avg_cost,
|
||||
price,
|
||||
market_value,
|
||||
gain_loss,
|
||||
weight,
|
||||
account,
|
||||
|
||||
pub fn label(self: PortfolioSortField) []const u8 {
|
||||
return switch (self) {
|
||||
.symbol => "Symbol",
|
||||
.shares => "Shares",
|
||||
.avg_cost => "Avg Cost",
|
||||
.price => "Price",
|
||||
.market_value => "Market Value",
|
||||
.gain_loss => "Gain/Loss",
|
||||
.weight => "Weight",
|
||||
.account => "Account",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn next(self: PortfolioSortField) ?PortfolioSortField {
|
||||
const fields = std.meta.fields(PortfolioSortField);
|
||||
const idx: usize = @intFromEnum(self);
|
||||
if (idx + 1 >= fields.len) return null;
|
||||
return @enumFromInt(idx + 1);
|
||||
}
|
||||
|
||||
pub fn prev(self: PortfolioSortField) ?PortfolioSortField {
|
||||
const idx: usize = @intFromEnum(self);
|
||||
if (idx == 0) return null;
|
||||
return @enumFromInt(idx - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const SortDirection = enum {
|
||||
asc,
|
||||
desc,
|
||||
|
||||
pub fn flip(self: SortDirection) SortDirection {
|
||||
return if (self == .asc) .desc else .asc;
|
||||
}
|
||||
|
||||
pub fn indicator(self: SortDirection) []const u8 {
|
||||
return if (self == .asc) "▲" else "▼";
|
||||
}
|
||||
};
|
||||
|
||||
/// A row in the portfolio view -- position header, lot detail, or special sections.
|
||||
const PortfolioRow = struct {
|
||||
kind: Kind,
|
||||
|
|
@ -135,6 +228,8 @@ const App = struct {
|
|||
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_count: usize = 0, // total styled lines in portfolio view
|
||||
portfolio_sort_field: PortfolioSortField = .symbol, // current sort column
|
||||
portfolio_sort_dir: SortDirection = .asc, // current sort direction
|
||||
|
||||
// Options navigation (inline expand/collapse like portfolio)
|
||||
options_cursor: usize = 0, // selected row in flattened options view
|
||||
|
|
@ -271,6 +366,34 @@ const App = struct {
|
|||
}
|
||||
if (self.active_tab == .portfolio and mouse.row > 0) {
|
||||
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
|
||||
// Click on column header row -> sort by that column
|
||||
if (self.portfolio_header_lines > 0 and content_row == self.portfolio_header_lines - 1) {
|
||||
// Column boundaries derived from sym_col_width (sw).
|
||||
// prefix(4) + Symbol(sw+1) + Shares(8+1) + AvgCost(10+1) + Price(10+1) + MV(16+1) + G/L(14+1) + Weight(8)
|
||||
const sw = fmt.sym_col_width;
|
||||
const col = @as(usize, @intCast(mouse.col));
|
||||
const new_field: ?PortfolioSortField =
|
||||
if (col < 4 + sw + 1) .symbol
|
||||
else if (col < 4 + sw + 10) .shares
|
||||
else if (col < 4 + sw + 21) .avg_cost
|
||||
else if (col < 4 + sw + 32) .price
|
||||
else if (col < 4 + sw + 49) .market_value
|
||||
else if (col < 4 + sw + 64) .gain_loss
|
||||
else if (col < 4 + sw + 73) .weight
|
||||
else if (col < 4 + sw + 87) null // Date (not sortable)
|
||||
else .account;
|
||||
if (new_field) |nf| {
|
||||
if (nf == self.portfolio_sort_field) {
|
||||
self.portfolio_sort_dir = self.portfolio_sort_dir.flip();
|
||||
} else {
|
||||
self.portfolio_sort_field = nf;
|
||||
self.portfolio_sort_dir = if (nf == .symbol or nf == .account) .asc else .desc;
|
||||
}
|
||||
self.sortPortfolioAllocations();
|
||||
self.rebuildPortfolioRows();
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
}
|
||||
if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) {
|
||||
const line_idx = content_row - self.portfolio_header_lines;
|
||||
if (line_idx < self.portfolio_line_count and line_idx < self.portfolio_line_to_row.len) {
|
||||
|
|
@ -531,6 +654,36 @@ const App = struct {
|
|||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
},
|
||||
.sort_col_next => {
|
||||
if (self.active_tab == .portfolio) {
|
||||
if (self.portfolio_sort_field.next()) |new_field| {
|
||||
self.portfolio_sort_field = new_field;
|
||||
self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
|
||||
self.sortPortfolioAllocations();
|
||||
self.rebuildPortfolioRows();
|
||||
}
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
},
|
||||
.sort_col_prev => {
|
||||
if (self.active_tab == .portfolio) {
|
||||
if (self.portfolio_sort_field.prev()) |new_field| {
|
||||
self.portfolio_sort_field = new_field;
|
||||
self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
|
||||
self.sortPortfolioAllocations();
|
||||
self.rebuildPortfolioRows();
|
||||
}
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
},
|
||||
.sort_reverse => {
|
||||
if (self.active_tab == .portfolio) {
|
||||
self.portfolio_sort_dir = self.portfolio_sort_dir.flip();
|
||||
self.sortPortfolioAllocations();
|
||||
self.rebuildPortfolioRows();
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -933,6 +1086,7 @@ const App = struct {
|
|||
}
|
||||
|
||||
self.portfolio_summary = summary;
|
||||
self.sortPortfolioAllocations();
|
||||
self.rebuildPortfolioRows();
|
||||
|
||||
if (self.symbol.len == 0 and summary.allocations.len > 0) {
|
||||
|
|
@ -975,6 +1129,31 @@ const App = struct {
|
|||
}
|
||||
}
|
||||
|
||||
fn sortPortfolioAllocations(self: *App) void {
|
||||
if (self.portfolio_summary) |s| {
|
||||
const SortCtx = struct {
|
||||
field: PortfolioSortField,
|
||||
dir: SortDirection,
|
||||
|
||||
fn lessThan(ctx: @This(), a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool {
|
||||
const lhs = if (ctx.dir == .asc) a else b;
|
||||
const rhs = if (ctx.dir == .asc) b else a;
|
||||
return switch (ctx.field) {
|
||||
.symbol => std.mem.lessThan(u8, lhs.symbol, rhs.symbol),
|
||||
.shares => lhs.shares < rhs.shares,
|
||||
.avg_cost => lhs.avg_cost < rhs.avg_cost,
|
||||
.price => lhs.current_price < rhs.current_price,
|
||||
.market_value => lhs.market_value < rhs.market_value,
|
||||
.gain_loss => lhs.unrealized_pnl < rhs.unrealized_pnl,
|
||||
.weight => lhs.weight < rhs.weight,
|
||||
.account => std.mem.lessThan(u8, lhs.account, rhs.account),
|
||||
};
|
||||
}
|
||||
};
|
||||
std.mem.sort(zfin.risk.Allocation, s.allocations, SortCtx{ .field = self.portfolio_sort_field, .dir = self.portfolio_sort_dir }, SortCtx.lessThan);
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuildPortfolioRows(self: *App) void {
|
||||
self.portfolio_rows.clearRetainingCapacity();
|
||||
|
||||
|
|
@ -1538,6 +1717,7 @@ const App = struct {
|
|||
}
|
||||
|
||||
self.portfolio_summary = summary;
|
||||
self.sortPortfolioAllocations();
|
||||
self.rebuildPortfolioRows();
|
||||
|
||||
if (missing > 0) {
|
||||
|
|
@ -1662,13 +1842,27 @@ const App = struct {
|
|||
buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s };
|
||||
}
|
||||
} else {
|
||||
for (0..@min(line.text.len, width)) |ci| {
|
||||
// UTF-8 aware rendering: byte index and column index tracked separately
|
||||
var col: usize = 0;
|
||||
var bi: usize = 0;
|
||||
while (bi < line.text.len and col < width) {
|
||||
var s = line.style;
|
||||
// Apply alt_style for the gain/loss column range
|
||||
if (line.alt_style) |alt| {
|
||||
if (ci >= line.alt_start and ci < line.alt_end) s = alt;
|
||||
if (col >= line.alt_start and col < line.alt_end) s = alt;
|
||||
}
|
||||
buf[row * width + ci] = .{ .char = .{ .grapheme = glyph(line.text[ci]) }, .style = s };
|
||||
const byte = line.text[bi];
|
||||
if (byte < 0x80) {
|
||||
// ASCII: single byte, single column
|
||||
buf[row * width + col] = .{ .char = .{ .grapheme = ascii_g[byte] }, .style = s };
|
||||
bi += 1;
|
||||
} else {
|
||||
// Multi-byte UTF-8: determine sequence length
|
||||
const seq_len: usize = if (byte >= 0xF0) 4 else if (byte >= 0xE0) 3 else if (byte >= 0xC0) 2 else 1;
|
||||
const end = @min(bi + seq_len, line.text.len);
|
||||
buf[row * width + col] = .{ .char = .{ .grapheme = line.text[bi..end] }, .style = s };
|
||||
bi = end;
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1759,8 +1953,29 @@ const App = struct {
|
|||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
// Column header (4-char prefix to match arrow(2)+star(2) in data rows)
|
||||
const hdr = try std.fmt.allocPrint(arena, " {s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{
|
||||
"Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account",
|
||||
// Active sort column gets a sort indicator within the column width
|
||||
const sf = self.portfolio_sort_field;
|
||||
const si = self.portfolio_sort_dir.indicator();
|
||||
// Build column labels with indicator embedded in padding
|
||||
// Left-aligned cols: "Name▲ " Right-aligned cols: " ▼Price"
|
||||
var sym_hdr_buf: [16]u8 = undefined;
|
||||
var shr_hdr_buf: [16]u8 = undefined;
|
||||
var avg_hdr_buf: [16]u8 = undefined;
|
||||
var prc_hdr_buf: [16]u8 = undefined;
|
||||
var mv_hdr_buf: [24]u8 = undefined;
|
||||
var gl_hdr_buf: [24]u8 = undefined;
|
||||
var wt_hdr_buf: [16]u8 = undefined;
|
||||
const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", fmt.sym_col_width, true, if (sf == .symbol) si else null);
|
||||
const shr_hdr = colLabel(&shr_hdr_buf, "Shares", 8, false, if (sf == .shares) si else null);
|
||||
const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", 10, false, if (sf == .avg_cost) si else null);
|
||||
const prc_hdr = colLabel(&prc_hdr_buf, "Price", 10, false, if (sf == .price) si else null);
|
||||
const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", 16, false, if (sf == .market_value) si else null);
|
||||
const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", 14, false, if (sf == .gain_loss) si else null);
|
||||
const wt_hdr = colLabel(&wt_hdr_buf, "Weight", 8, false, if (sf == .weight) si else null);
|
||||
const acct_ind: []const u8 = if (sf == .account) si else "";
|
||||
|
||||
const hdr = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} {s} {s:>13} {s}{s}", .{
|
||||
sym_hdr, shr_hdr, avg_hdr, prc_hdr, mv_hdr, gl_hdr, wt_hdr, "Date", acct_ind, "Account",
|
||||
});
|
||||
try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() });
|
||||
|
||||
|
|
@ -1840,7 +2055,7 @@ const App = struct {
|
|||
}
|
||||
}
|
||||
|
||||
const text = try std.fmt.allocPrint(arena, "{s}{s}{s:<6} {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.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, acct_col,
|
||||
});
|
||||
|
||||
|
|
@ -1855,8 +2070,8 @@ const App = struct {
|
|||
.text = text,
|
||||
.style = base_style,
|
||||
.alt_style = gl_style,
|
||||
.alt_start = 59,
|
||||
.alt_end = 59 + 14,
|
||||
.alt_start = gl_col_start,
|
||||
.alt_end = gl_col_start + 14,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1889,7 +2104,7 @@ const App = struct {
|
|||
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
||||
const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
|
||||
const acct_col: []const u8 = lot.account orelse "";
|
||||
const text = try std.fmt.allocPrint(arena, " {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{
|
||||
const text = try std.fmt.allocPrint(arena, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{
|
||||
status_str, lot.shares, lot_price_str, "", "", lot_gl_str, "", lot_date_col, acct_col,
|
||||
});
|
||||
const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
||||
|
|
@ -1898,8 +2113,8 @@ const App = struct {
|
|||
.text = text,
|
||||
.style = base_style,
|
||||
.alt_style = gl_col_style,
|
||||
.alt_start = 59,
|
||||
.alt_end = 59 + 14,
|
||||
.alt_start = gl_col_start,
|
||||
.alt_end = gl_col_start + 14,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -1913,7 +2128,7 @@ const App = struct {
|
|||
break :blk @as([]const u8, "--");
|
||||
} else "--";
|
||||
const star2: []const u8 = if (is_active_sym) "* " else " ";
|
||||
const text = try std.fmt.allocPrint(arena, " {s}{s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{
|
||||
const text = try std.fmt.allocPrint(arena, " {s}" ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{
|
||||
star2, row.symbol, "--", "--", ps, "--", "--", "watch", "",
|
||||
});
|
||||
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||
|
|
@ -2000,11 +2215,9 @@ const App = struct {
|
|||
},
|
||||
.cash_row => {
|
||||
if (row.lot) |lot| {
|
||||
var cash_amt_buf: [24]u8 = undefined;
|
||||
const text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{
|
||||
row.symbol, // account name
|
||||
fmt.fmtMoney(&cash_amt_buf, lot.shares),
|
||||
});
|
||||
var cash_row_buf: [80]u8 = undefined;
|
||||
const row_text = fmt.fmtCashRow(&cash_row_buf, row.symbol, lot.shares);
|
||||
const text = try std.fmt.allocPrint(arena, " {s}", .{row_text});
|
||||
const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
||||
try lines.append(arena, .{ .text = text, .style = row_style5 });
|
||||
}
|
||||
|
|
@ -2992,11 +3205,13 @@ const App = struct {
|
|||
"Scroll to bottom", "Page down", "Page up", "Select next",
|
||||
"Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)",
|
||||
"This help", "Edit portfolio/watchlist",
|
||||
"Reload portfolio from disk",
|
||||
"Toggle all calls (options)", "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 +/- 8 NTM", "Filter +/- 9 NTM",
|
||||
"Chart: next timeframe", "Chart: prev timeframe",
|
||||
"Sort: next column", "Sort: prev column", "Sort: reverse order",
|
||||
};
|
||||
|
||||
for (actions, 0..) |action, ai| {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue