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,
|
unrealized_return: f64,
|
||||||
/// True if current_price came from a manual override rather than live API data.
|
/// True if current_price came from a manual override rather than live API data.
|
||||||
is_manual_price: bool = false,
|
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.
|
/// Compute portfolio summary given positions and current prices.
|
||||||
|
|
@ -145,6 +147,7 @@ pub fn portfolioSummary(
|
||||||
.unrealized_pnl = mv - pos.total_cost,
|
.unrealized_pnl = mv - pos.total_cost,
|
||||||
.unrealized_return = if (pos.total_cost > 0) (mv / pos.total_cost) - 1.0 else 0,
|
.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,
|
.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);
|
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
|
// Include non-stock assets in the grand total
|
||||||
const cash_total = portfolio.totalCash();
|
const cash_total = portfolio.totalCash();
|
||||||
const cd_total = portfolio.totalCdFaceValue();
|
const cd_total = portfolio.totalCdFaceValue();
|
||||||
|
|
@ -1218,10 +1225,10 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
|
||||||
// Column headers
|
// Column headers
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try setFg(out, color, CLR_MUTED);
|
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",
|
"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);
|
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;
|
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),
|
a.symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost),
|
||||||
});
|
});
|
||||||
if (a.is_manual_price) try setFg(out, color, CLR_YELLOW);
|
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 out.print(" Cash\n", .{});
|
||||||
try reset(out, color);
|
try reset(out, color);
|
||||||
try setFg(out, color, CLR_MUTED);
|
try setFg(out, color, CLR_MUTED);
|
||||||
try out.print(" {s:<20} {s:>14}\n", .{ "Account", "Balance" });
|
var cash_hdr_buf: [80]u8 = undefined;
|
||||||
try out.print(" {s:->20} {s:->14}\n", .{ "", "" });
|
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);
|
try reset(out, color);
|
||||||
|
|
||||||
for (portfolio.lots) |lot| {
|
for (portfolio.lots) |lot| {
|
||||||
if (lot.lot_type != .cash) continue;
|
if (lot.lot_type != .cash) continue;
|
||||||
var cash_buf: [24]u8 = undefined;
|
|
||||||
const acct2: []const u8 = lot.account orelse "Unknown";
|
const acct2: []const u8 = lot.account orelse "Unknown";
|
||||||
try out.print(" {s:<20} {s:>14}\n", .{
|
var row_buf: [80]u8 = undefined;
|
||||||
acct2,
|
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares)});
|
||||||
fmt.fmtMoney(&cash_buf, lot.shares),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Cash total
|
// Cash total
|
||||||
|
var sep_buf: [80]u8 = undefined;
|
||||||
try setFg(out, color, CLR_MUTED);
|
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);
|
try reset(out, color);
|
||||||
var cash_total_buf: [24]u8 = undefined;
|
var total_buf: [80]u8 = undefined;
|
||||||
try setBold(out, color);
|
try setBold(out, color);
|
||||||
try out.print(" {s:>20} {s:>14}\n", .{
|
try out.print("{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash())});
|
||||||
"TOTAL", fmt.fmtMoney(&cash_total_buf, portfolio.totalCash()),
|
|
||||||
});
|
|
||||||
try reset(out, color);
|
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);
|
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;
|
}.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 "-";
|
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
|
||||||
|
|
||||||
try setFg(out, color, CLR_MUTED);
|
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), "", "",
|
status_str, lot.shares, fmt.fmtMoney2(&lot_price_buf, lot.open_price), "", "",
|
||||||
});
|
});
|
||||||
try reset(out, color);
|
try reset(out, color);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,103 @@ const Candle = @import("models/candle.zig").Candle;
|
||||||
const OptionContract = @import("models/option.zig").OptionContract;
|
const OptionContract = @import("models/option.zig").OptionContract;
|
||||||
const Lot = @import("models/portfolio.zig").Lot;
|
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 ────────────────────────────────────────
|
// ── Number formatters ────────────────────────────────────────
|
||||||
|
|
||||||
/// Format a dollar amount with commas and 2 decimals: $1,234.56
|
/// Format a dollar amount with commas and 2 decimals: $1,234.56
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,8 @@ pub const Position = struct {
|
||||||
closed_lots: u32,
|
closed_lots: u32,
|
||||||
/// Total realized P&L from closed lots
|
/// Total realized P&L from closed lots
|
||||||
realized_pnl: f64,
|
realized_pnl: f64,
|
||||||
|
/// Account name (shared across lots, or "Multiple" if mixed).
|
||||||
|
account: []const u8 = "",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A portfolio is a collection of lots.
|
/// A portfolio is a collection of lots.
|
||||||
|
|
@ -211,7 +213,15 @@ pub const Portfolio = struct {
|
||||||
.open_lots = 0,
|
.open_lots = 0,
|
||||||
.closed_lots = 0,
|
.closed_lots = 0,
|
||||||
.realized_pnl = 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()) {
|
if (lot.isOpen()) {
|
||||||
entry.value_ptr.shares += lot.shares;
|
entry.value_ptr.shares += lot.shares;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ pub const Action = enum {
|
||||||
options_filter_9,
|
options_filter_9,
|
||||||
chart_timeframe_next,
|
chart_timeframe_next,
|
||||||
chart_timeframe_prev,
|
chart_timeframe_prev,
|
||||||
|
sort_col_next,
|
||||||
|
sort_col_prev,
|
||||||
|
sort_reverse,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const KeyCombo = struct {
|
pub const KeyCombo = struct {
|
||||||
|
|
@ -117,6 +120,9 @@ const default_bindings = [_]Binding{
|
||||||
.{ .action = .options_filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } },
|
.{ .action = .options_filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } },
|
||||||
.{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } },
|
.{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } },
|
||||||
.{ .action = .chart_timeframe_prev, .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 {
|
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;
|
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 {
|
fn glyph(ch: u8) []const u8 {
|
||||||
if (ch < 128) return ascii_g[ch];
|
if (ch < 128) return ascii_g[ch];
|
||||||
return " ";
|
return " ";
|
||||||
|
|
@ -55,6 +97,57 @@ const InputMode = enum {
|
||||||
help,
|
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.
|
/// A row in the portfolio view -- position header, lot detail, or special sections.
|
||||||
const PortfolioRow = struct {
|
const PortfolioRow = struct {
|
||||||
kind: Kind,
|
kind: Kind,
|
||||||
|
|
@ -135,6 +228,8 @@ const App = struct {
|
||||||
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
|
||||||
|
portfolio_sort_field: PortfolioSortField = .symbol, // current sort column
|
||||||
|
portfolio_sort_dir: SortDirection = .asc, // current sort direction
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -271,6 +366,34 @@ const App = struct {
|
||||||
}
|
}
|
||||||
if (self.active_tab == .portfolio and mouse.row > 0) {
|
if (self.active_tab == .portfolio and mouse.row > 0) {
|
||||||
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
|
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) {
|
if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) {
|
||||||
const line_idx = content_row - self.portfolio_header_lines;
|
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) {
|
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();
|
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.portfolio_summary = summary;
|
||||||
|
self.sortPortfolioAllocations();
|
||||||
self.rebuildPortfolioRows();
|
self.rebuildPortfolioRows();
|
||||||
|
|
||||||
if (self.symbol.len == 0 and summary.allocations.len > 0) {
|
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 {
|
fn rebuildPortfolioRows(self: *App) void {
|
||||||
self.portfolio_rows.clearRetainingCapacity();
|
self.portfolio_rows.clearRetainingCapacity();
|
||||||
|
|
||||||
|
|
@ -1538,6 +1717,7 @@ const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.portfolio_summary = summary;
|
self.portfolio_summary = summary;
|
||||||
|
self.sortPortfolioAllocations();
|
||||||
self.rebuildPortfolioRows();
|
self.rebuildPortfolioRows();
|
||||||
|
|
||||||
if (missing > 0) {
|
if (missing > 0) {
|
||||||
|
|
@ -1662,13 +1842,27 @@ const App = struct {
|
||||||
buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s };
|
buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s };
|
||||||
}
|
}
|
||||||
} else {
|
} 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;
|
var s = line.style;
|
||||||
// Apply alt_style for the gain/loss column range
|
|
||||||
if (line.alt_style) |alt| {
|
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() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
// Column header (4-char prefix to match arrow(2)+star(2) in data rows)
|
// Column header (4-char prefix to match arrow(2)+star(2) in data rows)
|
||||||
const hdr = try std.fmt.allocPrint(arena, " {s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{
|
// Active sort column gets a sort indicator within the column width
|
||||||
"Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account",
|
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() });
|
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,
|
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,
|
.text = text,
|
||||||
.style = base_style,
|
.style = base_style,
|
||||||
.alt_style = gl_style,
|
.alt_style = gl_style,
|
||||||
.alt_start = 59,
|
.alt_start = gl_col_start,
|
||||||
.alt_end = 59 + 14,
|
.alt_end = gl_col_start + 14,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1889,7 +2104,7 @@ const App = struct {
|
||||||
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
||||||
const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
|
const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
|
||||||
const acct_col: []const u8 = lot.account orelse "";
|
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,
|
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();
|
const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
||||||
|
|
@ -1898,8 +2113,8 @@ const App = struct {
|
||||||
.text = text,
|
.text = text,
|
||||||
.style = base_style,
|
.style = base_style,
|
||||||
.alt_style = gl_col_style,
|
.alt_style = gl_col_style,
|
||||||
.alt_start = 59,
|
.alt_start = gl_col_start,
|
||||||
.alt_end = 59 + 14,
|
.alt_end = gl_col_start + 14,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1913,7 +2128,7 @@ const App = struct {
|
||||||
break :blk @as([]const u8, "--");
|
break :blk @as([]const u8, "--");
|
||||||
} else "--";
|
} else "--";
|
||||||
const star2: []const u8 = if (is_active_sym) "* " 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", "",
|
star2, row.symbol, "--", "--", ps, "--", "--", "watch", "",
|
||||||
});
|
});
|
||||||
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||||
|
|
@ -2000,11 +2215,9 @@ const App = struct {
|
||||||
},
|
},
|
||||||
.cash_row => {
|
.cash_row => {
|
||||||
if (row.lot) |lot| {
|
if (row.lot) |lot| {
|
||||||
var cash_amt_buf: [24]u8 = undefined;
|
var cash_row_buf: [80]u8 = undefined;
|
||||||
const text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{
|
const row_text = fmt.fmtCashRow(&cash_row_buf, row.symbol, lot.shares);
|
||||||
row.symbol, // account name
|
const text = try std.fmt.allocPrint(arena, " {s}", .{row_text});
|
||||||
fmt.fmtMoney(&cash_amt_buf, lot.shares),
|
|
||||||
});
|
|
||||||
const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
||||||
try lines.append(arena, .{ .text = text, .style = row_style5 });
|
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",
|
"Scroll to bottom", "Page down", "Page up", "Select next",
|
||||||
"Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)",
|
"Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)",
|
||||||
"This help", "Edit portfolio/watchlist",
|
"This help", "Edit portfolio/watchlist",
|
||||||
|
"Reload portfolio from disk",
|
||||||
"Toggle all calls (options)", "Toggle all puts (options)",
|
"Toggle all calls (options)", "Toggle all puts (options)",
|
||||||
"Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM",
|
"Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM",
|
||||||
"Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM",
|
"Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM",
|
||||||
"Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM",
|
"Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM",
|
||||||
"Chart: next timeframe", "Chart: prev timeframe",
|
"Chart: next timeframe", "Chart: prev timeframe",
|
||||||
|
"Sort: next column", "Sort: prev column", "Sort: reverse order",
|
||||||
};
|
};
|
||||||
|
|
||||||
for (actions, 0..) |action, ai| {
|
for (actions, 0..) |action, ai| {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue