create a view model for options and cds that is reused between cli/tui
This commit is contained in:
parent
9de40e8219
commit
1f9f90357f
5 changed files with 368 additions and 172 deletions
|
|
@ -2,6 +2,18 @@ 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
|
||||||
|
|
@ -364,101 +376,77 @@ pub fn display(
|
||||||
|
|
||||||
// Options section
|
// Options section
|
||||||
if (portfolio.hasType(.option)) {
|
if (portfolio.hasType(.option)) {
|
||||||
try out.print("\n", .{});
|
var prepared_opts = try views.Options.init(allocator, portfolio.lots, null);
|
||||||
try cli.setBold(out, color);
|
defer prepared_opts.deinit();
|
||||||
try out.print(" Options\n", .{});
|
if (prepared_opts.items.len > 0) {
|
||||||
try cli.reset(out, color);
|
try out.print("\n", .{});
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setBold(out, color);
|
||||||
try out.print(" {s:<30} {s:>6} {s:>12} {s:>14} {s}\n", .{
|
try out.print(" Options\n", .{});
|
||||||
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
|
try cli.reset(out, color);
|
||||||
});
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14} {s:->10}\n", .{
|
try out.print(views.OptionsLayout.header ++ "\n", views.OptionsLayout.header_labels);
|
||||||
"", "", "", "", "",
|
try out.print(views.OptionsLayout.separator ++ "\n", views.OptionsLayout.separator_fills);
|
||||||
});
|
try cli.reset(out, color);
|
||||||
try cli.reset(out, color);
|
|
||||||
|
|
||||||
var opt_total_cost: f64 = 0;
|
var opt_total_premium: f64 = 0;
|
||||||
for (portfolio.lots) |lot| {
|
for (prepared_opts.items) |po| {
|
||||||
if (lot.security_type != .option) continue;
|
opt_total_premium += po.premium;
|
||||||
const qty = lot.shares;
|
const text = po.columns[0].text;
|
||||||
const cost_per = lot.open_price;
|
const prem_start = po.premium_col_start;
|
||||||
const total_cost_opt = @abs(qty) * cost_per;
|
const prem_end = @min(prem_start + views.OptionsLayout.premium_w, text.len);
|
||||||
opt_total_cost += total_cost_opt;
|
// Pre-premium portion
|
||||||
var cost_per_buf: [24]u8 = undefined;
|
try setIntentFg(out, color, po.row_style);
|
||||||
var total_cost_buf: [24]u8 = undefined;
|
try out.print("{s}", .{text[0..prem_start]});
|
||||||
const acct: []const u8 = lot.account orelse "";
|
// Premium column
|
||||||
try out.print(" {s:<30} {d:>6.0} {s:>12} {s:>14} {s}\n", .{
|
try setIntentFg(out, color, po.premium_style);
|
||||||
lot.symbol,
|
try out.print("{s}", .{text[prem_start..prem_end]});
|
||||||
qty,
|
// Post-premium portion (account)
|
||||||
fmt.fmtMoneyAbs(&cost_per_buf, cost_per),
|
try setIntentFg(out, color, po.row_style);
|
||||||
fmt.fmtMoneyAbs(&total_cost_buf, total_cost_opt),
|
if (prem_end < text.len) try out.print("{s}", .{text[prem_end..]});
|
||||||
acct,
|
try cli.reset(out, color);
|
||||||
|
try out.print("\n", .{});
|
||||||
|
}
|
||||||
|
// Options total
|
||||||
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
|
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" });
|
||||||
|
try cli.reset(out, color);
|
||||||
|
var opt_total_buf: [24]u8 = undefined;
|
||||||
|
try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{
|
||||||
|
"", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_premium),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Options total
|
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
||||||
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" });
|
|
||||||
try cli.reset(out, color);
|
|
||||||
var opt_total_buf: [24]u8 = undefined;
|
|
||||||
try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{
|
|
||||||
"", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_cost),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CDs section
|
// CDs section
|
||||||
if (portfolio.hasType(.cd)) {
|
if (portfolio.hasType(.cd)) {
|
||||||
try out.print("\n", .{});
|
var prepared_cds = try views.CDs.init(allocator, portfolio.lots, null);
|
||||||
try cli.setBold(out, color);
|
defer prepared_cds.deinit();
|
||||||
try out.print(" Certificates of Deposit\n", .{});
|
if (prepared_cds.items.len > 0) {
|
||||||
try cli.reset(out, color);
|
try out.print("\n", .{});
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setBold(out, color);
|
||||||
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
|
try out.print(" Certificates of Deposit\n", .{});
|
||||||
"CUSIP", "Face Value", "Rate", "Maturity", "Description",
|
try cli.reset(out, color);
|
||||||
});
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
try out.print(" {s:->12} {s:->14} {s:->7} {s:->10} {s:->30}\n", .{
|
try out.print(views.CDsLayout.header ++ "\n", views.CDsLayout.header_labels);
|
||||||
"", "", "", "", "",
|
try out.print(views.CDsLayout.separator ++ "\n", views.CDsLayout.separator_fills);
|
||||||
});
|
try cli.reset(out, color);
|
||||||
try cli.reset(out, color);
|
|
||||||
|
|
||||||
// Collect and sort CDs by maturity date (earliest first)
|
var cd_section_total: f64 = 0;
|
||||||
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
|
for (prepared_cds.items) |pc| {
|
||||||
defer cd_lots.deinit(allocator);
|
cd_section_total += pc.lot.shares;
|
||||||
for (portfolio.lots) |lot| {
|
try setIntentFg(out, color, pc.row_style);
|
||||||
if (lot.security_type == .cd) {
|
try out.print("{s}\n", .{pc.text});
|
||||||
try cd_lots.append(allocator, lot);
|
try cli.reset(out, color);
|
||||||
}
|
}
|
||||||
}
|
// CD total
|
||||||
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
|
try out.print(" {s:->12} {s:->14}\n", .{ "", "" });
|
||||||
var cd_section_total: f64 = 0;
|
try cli.reset(out, color);
|
||||||
for (cd_lots.items) |lot| {
|
var cd_total_buf: [24]u8 = undefined;
|
||||||
cd_section_total += lot.shares;
|
try out.print(" {s:>12} {s:>14}\n", .{
|
||||||
var face_buf: [24]u8 = undefined;
|
"TOTAL", fmt.fmtMoneyAbs(&cd_total_buf, cd_section_total),
|
||||||
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 > 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
|
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
||||||
try out.print(" {s:->12} {s:->14}\n", .{ "", "" });
|
|
||||||
try cli.reset(out, color);
|
|
||||||
var cd_total_buf: [24]u8 = undefined;
|
|
||||||
try out.print(" {s:>12} {s:>14}\n", .{
|
|
||||||
"TOTAL", fmt.fmtMoneyAbs(&cd_total_buf, cd_section_total),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cash section
|
// Cash section
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,17 @@ pub fn lotMaturityThenSymbolSortFn(_: void, a: Lot, b: Lot) bool {
|
||||||
return std.mem.lessThan(u8, a.symbol, b.symbol);
|
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,
|
||||||
|
|
|
||||||
18
src/tui.zig
18
src/tui.zig
|
|
@ -2,6 +2,7 @@ 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");
|
||||||
|
|
@ -181,6 +182,13 @@ 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 };
|
||||||
};
|
};
|
||||||
|
|
@ -289,6 +297,8 @@ 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
|
||||||
|
|
@ -1318,6 +1328,13 @@ 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();
|
||||||
|
|
@ -1325,6 +1342,7 @@ 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);
|
self.account_list.deinit(self.allocator);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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");
|
||||||
|
|
@ -32,6 +33,16 @@ 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.
|
||||||
|
|
@ -268,6 +279,7 @@ 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| {
|
||||||
|
|
@ -415,50 +427,42 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
|
|
||||||
// Options section (sorted by expiration date, then symbol; filtered by account)
|
// Options section (sorted by expiration date, then symbol; filtered by account)
|
||||||
if (app.portfolio) |pf| {
|
if (app.portfolio) |pf| {
|
||||||
if (pf.hasType(.option)) {
|
app.prepared_options = views.Options.init(app.allocator, pf.lots, app.account_filter) catch null;
|
||||||
var option_lots: std.ArrayList(zfin.Lot) = .empty;
|
if (app.prepared_options) |opts| {
|
||||||
defer option_lots.deinit(app.allocator);
|
if (opts.items.len > 0) {
|
||||||
for (pf.lots) |lot| {
|
|
||||||
if (lot.security_type == .option and matchesAccountFilter(app, lot.account)) {
|
|
||||||
option_lots.append(app.allocator, lot) catch continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (option_lots.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 {};
|
||||||
std.mem.sort(zfin.Lot, option_lots.items, {}, fmt.lotMaturityThenSymbolSortFn);
|
for (opts.items) |po| {
|
||||||
for (option_lots.items) |lot| {
|
|
||||||
app.portfolio_rows.append(app.allocator, .{
|
app.portfolio_rows.append(app.allocator, .{
|
||||||
.kind = .option_row,
|
.kind = .option_row,
|
||||||
.symbol = lot.symbol,
|
.symbol = po.lot.symbol,
|
||||||
.lot = lot,
|
.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;
|
}) catch continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CDs section (sorted by maturity date, earliest first; filtered by account)
|
// CDs section (sorted by maturity date, earliest first; filtered by account)
|
||||||
if (pf.hasType(.cd)) {
|
app.prepared_cds = views.CDs.init(app.allocator, pf.lots, app.account_filter) catch null;
|
||||||
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
|
if (app.prepared_cds) |cds| {
|
||||||
defer cd_lots.deinit(app.allocator);
|
if (cds.items.len > 0) {
|
||||||
for (pf.lots) |lot| {
|
|
||||||
if (lot.security_type == .cd and matchesAccountFilter(app, lot.account)) {
|
|
||||||
cd_lots.append(app.allocator, lot) catch continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cd_lots.items.len > 0) {
|
|
||||||
app.portfolio_rows.append(app.allocator, .{
|
app.portfolio_rows.append(app.allocator, .{
|
||||||
.kind = .section_header,
|
.kind = .section_header,
|
||||||
.symbol = "Certificates of Deposit",
|
.symbol = "Certificates of Deposit",
|
||||||
}) catch {};
|
}) catch {};
|
||||||
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
|
for (cds.items) |pc| {
|
||||||
for (cd_lots.items) |lot| {
|
|
||||||
app.portfolio_rows.append(app.allocator, .{
|
app.portfolio_rows.append(app.allocator, .{
|
||||||
.kind = .cd_row,
|
.kind = .cd_row,
|
||||||
.symbol = lot.symbol,
|
.symbol = pc.lot.symbol,
|
||||||
.lot = lot,
|
.lot = pc.lot,
|
||||||
|
.prepared_text = pc.text,
|
||||||
|
.row_style = pc.row_style,
|
||||||
}) catch continue;
|
}) catch continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1016,82 +1020,29 @@ 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, " {s:<30} {s:>6} {s:>12} {s:>14} {s}", .{
|
const col_hdr = try std.fmt.allocPrint(arena, views.OptionsLayout.header, views.OptionsLayout.header_labels);
|
||||||
"Contract", "Qty", "Cost/Ctrct", "Premium", "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, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
|
const col_hdr = try std.fmt.allocPrint(arena, views.CDsLayout.header, views.CDsLayout.header_labels);
|
||||||
"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.lot) |lot| {
|
if (row.prepared_text) |text| {
|
||||||
// Options: symbol (description), qty (contracts), cost/contract, premium (+/-), account
|
const row_style2 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
|
||||||
const qty = lot.shares; // negative = short
|
const prem_style = if (is_cursor) th.selectStyle() else mapIntent(th, row.premium_style);
|
||||||
const cost_per = lot.open_price; // per-contract cost
|
|
||||||
const total_premium = @abs(qty) * cost_per * lot.multiplier;
|
|
||||||
// Short = received premium (+), Long = paid premium (-)
|
|
||||||
const received = qty < 0;
|
|
||||||
var cost_buf3: [24]u8 = undefined;
|
|
||||||
var prem_val_buf: [24]u8 = undefined;
|
|
||||||
const prem_money = fmt.fmtMoneyAbs(&prem_val_buf, total_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_col2: []const u8 = lot.account orelse "";
|
|
||||||
// Column layout: 4 + 30 + 1 + 6 + 1 + 12 + 1 = 55 (premium start)
|
|
||||||
const prem_col_start: usize = 55;
|
|
||||||
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),
|
|
||||||
prem_str,
|
|
||||||
acct_col2,
|
|
||||||
});
|
|
||||||
const today = fmt.todayDate();
|
|
||||||
const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false;
|
|
||||||
const row_style2 = if (is_cursor) th.selectStyle() else if (is_expired) th.mutedStyle() else th.contentStyle();
|
|
||||||
const prem_style = if (is_cursor) th.selectStyle() else if (is_expired) th.mutedStyle() else if (received) th.positiveStyle() else th.negativeStyle();
|
|
||||||
try lines.append(arena, .{
|
try lines.append(arena, .{
|
||||||
.text = text,
|
.text = text,
|
||||||
.style = row_style2,
|
.style = row_style2,
|
||||||
.alt_style = prem_style,
|
.alt_style = prem_style,
|
||||||
.alt_start = prem_col_start,
|
.alt_start = row.premium_col_start,
|
||||||
.alt_end = prem_col_start + 14,
|
.alt_end = row.premium_col_start + 14,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.cd_row => {
|
.cd_row => {
|
||||||
if (row.lot) |lot| {
|
if (row.prepared_text) |text| {
|
||||||
// CDs: symbol (CUSIP), face value, rate%, maturity date, note, account
|
const row_style3 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
|
||||||
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 today = fmt.todayDate();
|
|
||||||
const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false;
|
|
||||||
const row_style3 = if (is_cursor) th.selectStyle() else if (is_expired) th.mutedStyle() else th.contentStyle();
|
|
||||||
try lines.append(arena, .{ .text = text, .style = row_style3 });
|
try lines.append(arena, .{ .text = text, .style = row_style3 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
228
src/views/portfolio_sections.zig
Normal file
228
src/views/portfolio_sections.zig
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
//! 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