ai: options, cds, cash and bug fixes
This commit is contained in:
parent
529143cf49
commit
bec1dcff2c
5 changed files with 597 additions and 35 deletions
45
src/cache/store.zig
vendored
45
src/cache/store.zig
vendored
|
|
@ -818,6 +818,16 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co
|
||||||
try writer.writeAll("#!srfv1\n");
|
try writer.writeAll("#!srfv1\n");
|
||||||
for (lots) |lot| {
|
for (lots) |lot| {
|
||||||
var od_buf: [10]u8 = undefined;
|
var od_buf: [10]u8 = undefined;
|
||||||
|
// Write security_type if not stock (stock is the default)
|
||||||
|
if (lot.lot_type != .stock) {
|
||||||
|
const type_str: []const u8 = switch (lot.lot_type) {
|
||||||
|
.option => "option",
|
||||||
|
.cd => "cd",
|
||||||
|
.cash => "cash",
|
||||||
|
.stock => unreachable,
|
||||||
|
};
|
||||||
|
try writer.print("security_type::{s},", .{type_str});
|
||||||
|
}
|
||||||
try writer.print("symbol::{s},shares:num:{d},open_date::{s},open_price:num:{d}", .{
|
try writer.print("symbol::{s},shares:num:{d},open_date::{s},open_price:num:{d}", .{
|
||||||
lot.symbol, lot.shares, lot.open_date.format(&od_buf), lot.open_price,
|
lot.symbol, lot.shares, lot.open_date.format(&od_buf), lot.open_price,
|
||||||
});
|
});
|
||||||
|
|
@ -834,6 +844,13 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co
|
||||||
if (lot.account) |a| {
|
if (lot.account) |a| {
|
||||||
try writer.print(",account::{s}", .{a});
|
try writer.print(",account::{s}", .{a});
|
||||||
}
|
}
|
||||||
|
if (lot.maturity_date) |md| {
|
||||||
|
var md_buf: [10]u8 = undefined;
|
||||||
|
try writer.print(",maturity_date::{s}", .{md.format(&md_buf)});
|
||||||
|
}
|
||||||
|
if (lot.rate) |r| {
|
||||||
|
try writer.print(",rate:num:{d}", .{r});
|
||||||
|
}
|
||||||
try writer.writeAll("\n");
|
try writer.writeAll("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -842,6 +859,7 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co
|
||||||
|
|
||||||
/// Deserialize a portfolio from SRF data. Caller owns the returned Portfolio.
|
/// Deserialize a portfolio from SRF data. Caller owns the returned Portfolio.
|
||||||
pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Portfolio {
|
pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Portfolio {
|
||||||
|
const LotType = @import("../models/portfolio.zig").LotType;
|
||||||
var lots: std.ArrayList(Lot) = .empty;
|
var lots: std.ArrayList(Lot) = .empty;
|
||||||
errdefer {
|
errdefer {
|
||||||
for (lots.items) |lot| {
|
for (lots.items) |lot| {
|
||||||
|
|
@ -866,6 +884,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por
|
||||||
var sym_raw: ?[]const u8 = null;
|
var sym_raw: ?[]const u8 = null;
|
||||||
var note_raw: ?[]const u8 = null;
|
var note_raw: ?[]const u8 = null;
|
||||||
var account_raw: ?[]const u8 = null;
|
var account_raw: ?[]const u8 = null;
|
||||||
|
var sec_type_raw: ?[]const u8 = null;
|
||||||
|
|
||||||
for (record.fields) |field| {
|
for (record.fields) |field| {
|
||||||
if (std.mem.eql(u8, field.key, "symbol")) {
|
if (std.mem.eql(u8, field.key, "symbol")) {
|
||||||
|
|
@ -890,10 +909,34 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por
|
||||||
if (field.value) |v| note_raw = switch (v) { .string => |s| s, else => null };
|
if (field.value) |v| note_raw = switch (v) { .string => |s| s, else => null };
|
||||||
} else if (std.mem.eql(u8, field.key, "account")) {
|
} else if (std.mem.eql(u8, field.key, "account")) {
|
||||||
if (field.value) |v| account_raw = switch (v) { .string => |s| s, else => null };
|
if (field.value) |v| account_raw = switch (v) { .string => |s| s, else => null };
|
||||||
|
} else if (std.mem.eql(u8, field.key, "security_type")) {
|
||||||
|
if (field.value) |v| sec_type_raw = switch (v) { .string => |s| s, else => null };
|
||||||
|
} else if (std.mem.eql(u8, field.key, "maturity_date")) {
|
||||||
|
if (field.value) |v| {
|
||||||
|
const str = switch (v) { .string => |s| s, else => continue };
|
||||||
|
lot.maturity_date = Date.parse(str) catch null;
|
||||||
|
}
|
||||||
|
} else if (std.mem.eql(u8, field.key, "rate")) {
|
||||||
|
if (field.value) |v| {
|
||||||
|
const r = Store.numVal(v);
|
||||||
|
if (r > 0) lot.rate = r;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sym_raw) |s| {
|
// Determine lot type
|
||||||
|
if (sec_type_raw) |st| {
|
||||||
|
lot.lot_type = LotType.fromString(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cash lots don't require a symbol -- generate a placeholder
|
||||||
|
if (lot.lot_type == .cash) {
|
||||||
|
if (sym_raw == null) {
|
||||||
|
lot.symbol = try allocator.dupe(u8, "CASH");
|
||||||
|
} else {
|
||||||
|
lot.symbol = try allocator.dupe(u8, sym_raw.?);
|
||||||
|
}
|
||||||
|
} else if (sym_raw) |s| {
|
||||||
lot.symbol = try allocator.dupe(u8, s);
|
lot.symbol = try allocator.dupe(u8, s);
|
||||||
} else continue;
|
} else continue;
|
||||||
|
|
||||||
|
|
|
||||||
184
src/cli/main.zig
184
src/cli/main.zig
|
|
@ -991,17 +991,19 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get positions
|
// Get stock/ETF positions (excludes options, CDs, cash)
|
||||||
const positions = try portfolio.positions(allocator);
|
const positions = try portfolio.positions(allocator);
|
||||||
defer allocator.free(positions);
|
defer allocator.free(positions);
|
||||||
|
|
||||||
// Get unique symbols and fetch current prices
|
// Get unique stock/ETF symbols and fetch current prices
|
||||||
const syms = try portfolio.symbols(allocator);
|
const syms = try portfolio.stockSymbols(allocator);
|
||||||
defer allocator.free(syms);
|
defer allocator.free(syms);
|
||||||
|
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
defer prices.deinit();
|
defer prices.deinit();
|
||||||
|
|
||||||
|
var fail_count: usize = 0;
|
||||||
|
|
||||||
if (config.twelvedata_key) |td_key| {
|
if (config.twelvedata_key) |td_key| {
|
||||||
var td = zfin.TwelveData.init(allocator, td_key);
|
var td = zfin.TwelveData.init(allocator, td_key);
|
||||||
defer td.deinit();
|
defer td.deinit();
|
||||||
|
|
@ -1018,13 +1020,23 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
|
||||||
defer q.deinit();
|
defer q.deinit();
|
||||||
const price = q.close();
|
const price = q.close();
|
||||||
if (price > 0) try prices.put(sym, price);
|
if (price > 0) try prices.put(sym, price);
|
||||||
} else |_| {}
|
} else |_| {
|
||||||
} else |_| {}
|
fail_count += 1;
|
||||||
|
}
|
||||||
|
} else |_| {
|
||||||
|
fail_count += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try stderr_print("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n");
|
try stderr_print("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fail_count > 0) {
|
||||||
|
var warn_msg_buf: [128]u8 = undefined;
|
||||||
|
const warn_msg = std.fmt.bufPrint(&warn_msg_buf, "Warning: {d} securities failed to load prices\n", .{fail_count}) catch "Warning: some securities failed\n";
|
||||||
|
try stderr_print(warn_msg);
|
||||||
|
}
|
||||||
|
|
||||||
// Compute summary
|
// Compute summary
|
||||||
var summary = zfin.risk.portfolioSummary(allocator, positions, prices) catch {
|
var summary = zfin.risk.portfolioSummary(allocator, positions, prices) catch {
|
||||||
try stderr_print("Error computing portfolio summary.\n");
|
try stderr_print("Error computing portfolio summary.\n");
|
||||||
|
|
@ -1032,6 +1044,23 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
|
||||||
};
|
};
|
||||||
defer summary.deinit(allocator);
|
defer summary.deinit(allocator);
|
||||||
|
|
||||||
|
// Include non-stock assets in the grand total
|
||||||
|
const cash_total = portfolio.totalCash();
|
||||||
|
const cd_total = portfolio.totalCdFaceValue();
|
||||||
|
const opt_total = portfolio.totalOptionCost();
|
||||||
|
const non_stock = cash_total + cd_total + opt_total;
|
||||||
|
summary.total_value += non_stock;
|
||||||
|
summary.total_cost += non_stock;
|
||||||
|
if (summary.total_cost > 0) {
|
||||||
|
summary.unrealized_return = summary.unrealized_pnl / summary.total_cost;
|
||||||
|
}
|
||||||
|
// Reweight allocations against grand total
|
||||||
|
if (summary.total_value > 0) {
|
||||||
|
for (summary.allocations) |*a| {
|
||||||
|
a.weight = a.market_value / summary.total_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var buf: [32768]u8 = undefined;
|
var buf: [32768]u8 = undefined;
|
||||||
var writer = std.fs.File.stdout().writer(&buf);
|
var writer = std.fs.File.stdout().writer(&buf);
|
||||||
const out = &writer.interface;
|
const out = &writer.interface;
|
||||||
|
|
@ -1059,10 +1088,11 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lot counts
|
// Lot counts (stocks/ETFs only)
|
||||||
var open_lots: u32 = 0;
|
var open_lots: u32 = 0;
|
||||||
var closed_lots: u32 = 0;
|
var closed_lots: u32 = 0;
|
||||||
for (portfolio.lots) |lot| {
|
for (portfolio.lots) |lot| {
|
||||||
|
if (lot.lot_type != .stock) continue;
|
||||||
if (lot.isOpen()) open_lots += 1 else closed_lots += 1;
|
if (lot.isOpen()) open_lots += 1 else closed_lots += 1;
|
||||||
}
|
}
|
||||||
try setFg(out, color, CLR_MUTED);
|
try setFg(out, color, CLR_MUTED);
|
||||||
|
|
@ -1082,11 +1112,11 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
|
||||||
|
|
||||||
// Position rows with lot detail
|
// Position rows with lot detail
|
||||||
for (summary.allocations) |a| {
|
for (summary.allocations) |a| {
|
||||||
// Count lots for this symbol
|
// Count stock lots for this symbol
|
||||||
var lots_for_sym: std.ArrayList(zfin.Lot) = .empty;
|
var lots_for_sym: std.ArrayList(zfin.Lot) = .empty;
|
||||||
defer lots_for_sym.deinit(allocator);
|
defer lots_for_sym.deinit(allocator);
|
||||||
for (portfolio.lots) |lot| {
|
for (portfolio.lots) |lot| {
|
||||||
if (std.mem.eql(u8, lot.symbol, a.symbol)) {
|
if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) {
|
||||||
try lots_for_sym.append(allocator, lot);
|
try lots_for_sym.append(allocator, lot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1202,6 +1232,144 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
|
||||||
try reset(out, color);
|
try reset(out, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options section
|
||||||
|
if (portfolio.hasType(.option)) {
|
||||||
|
try out.print("\n", .{});
|
||||||
|
try setBold(out, color);
|
||||||
|
try out.print(" Options\n", .{});
|
||||||
|
try reset(out, color);
|
||||||
|
try setFg(out, color, CLR_MUTED);
|
||||||
|
try out.print(" {s:<30} {s:>6} {s:>12} {s:>14} {s}\n", .{
|
||||||
|
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
|
||||||
|
});
|
||||||
|
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14} {s:->10}\n", .{
|
||||||
|
"", "", "", "", "",
|
||||||
|
});
|
||||||
|
try reset(out, color);
|
||||||
|
|
||||||
|
var opt_total_cost: f64 = 0;
|
||||||
|
for (portfolio.lots) |lot| {
|
||||||
|
if (lot.lot_type != .option) continue;
|
||||||
|
const qty = lot.shares;
|
||||||
|
const cost_per = lot.open_price;
|
||||||
|
const total_cost_opt = @abs(qty) * cost_per;
|
||||||
|
opt_total_cost += total_cost_opt;
|
||||||
|
var cost_per_buf: [24]u8 = undefined;
|
||||||
|
var total_cost_buf: [24]u8 = undefined;
|
||||||
|
const acct: []const u8 = lot.account orelse "";
|
||||||
|
try out.print(" {s:<30} {d:>6.0} {s:>12} {s:>14} {s}\n", .{
|
||||||
|
lot.symbol,
|
||||||
|
qty,
|
||||||
|
fmt.fmtMoney2(&cost_per_buf, cost_per),
|
||||||
|
fmt.fmtMoney(&total_cost_buf, total_cost_opt),
|
||||||
|
acct,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Options total
|
||||||
|
try setFg(out, color, CLR_MUTED);
|
||||||
|
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" });
|
||||||
|
try reset(out, color);
|
||||||
|
var opt_total_buf: [24]u8 = undefined;
|
||||||
|
try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{
|
||||||
|
"", "", "TOTAL", fmt.fmtMoney(&opt_total_buf, opt_total_cost),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CDs section
|
||||||
|
if (portfolio.hasType(.cd)) {
|
||||||
|
try out.print("\n", .{});
|
||||||
|
try setBold(out, color);
|
||||||
|
try out.print(" Certificates of Deposit\n", .{});
|
||||||
|
try reset(out, color);
|
||||||
|
try setFg(out, color, CLR_MUTED);
|
||||||
|
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
|
||||||
|
"CUSIP", "Face Value", "Rate", "Maturity", "Description",
|
||||||
|
});
|
||||||
|
try out.print(" {s:->12} {s:->14} {s:->7} {s:->10} {s:->30}\n", .{
|
||||||
|
"", "", "", "", "",
|
||||||
|
});
|
||||||
|
try reset(out, color);
|
||||||
|
|
||||||
|
// Collect and sort CDs by maturity date (earliest first)
|
||||||
|
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
|
||||||
|
defer cd_lots.deinit(allocator);
|
||||||
|
for (portfolio.lots) |lot| {
|
||||||
|
if (lot.lot_type == .cd) {
|
||||||
|
try cd_lots.append(allocator, lot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std.mem.sort(zfin.Lot, cd_lots.items, {}, struct {
|
||||||
|
fn f(ctx: void, a: zfin.Lot, b: zfin.Lot) bool {
|
||||||
|
_ = ctx;
|
||||||
|
const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32);
|
||||||
|
const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32);
|
||||||
|
return ad < bd;
|
||||||
|
}
|
||||||
|
}.f);
|
||||||
|
|
||||||
|
var cd_section_total: f64 = 0;
|
||||||
|
for (cd_lots.items) |lot| {
|
||||||
|
cd_section_total += lot.shares;
|
||||||
|
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 > 50) note_str[0..50] else note_str;
|
||||||
|
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
|
||||||
|
lot.symbol,
|
||||||
|
fmt.fmtMoney(&face_buf, lot.shares),
|
||||||
|
rate_str,
|
||||||
|
mat_str,
|
||||||
|
note_display,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// CD total
|
||||||
|
try setFg(out, color, CLR_MUTED);
|
||||||
|
try out.print(" {s:->12} {s:->14}\n", .{ "", "" });
|
||||||
|
try reset(out, color);
|
||||||
|
var cd_total_buf: [24]u8 = undefined;
|
||||||
|
try out.print(" {s:>12} {s:>14}\n", .{
|
||||||
|
"TOTAL", fmt.fmtMoney(&cd_total_buf, cd_section_total),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cash section
|
||||||
|
if (portfolio.hasType(.cash)) {
|
||||||
|
try out.print("\n", .{});
|
||||||
|
try setBold(out, color);
|
||||||
|
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", .{ "", "" });
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Cash total
|
||||||
|
try setFg(out, color, CLR_MUTED);
|
||||||
|
try out.print(" {s:->20} {s:->14}\n", .{ "", "" });
|
||||||
|
try reset(out, color);
|
||||||
|
var cash_total_buf: [24]u8 = undefined;
|
||||||
|
try setBold(out, color);
|
||||||
|
try out.print(" {s:>20} {s:>14}\n", .{
|
||||||
|
"TOTAL", fmt.fmtMoney(&cash_total_buf, portfolio.totalCash()),
|
||||||
|
});
|
||||||
|
try reset(out, color);
|
||||||
|
}
|
||||||
|
|
||||||
// Watchlist
|
// Watchlist
|
||||||
if (watchlist_path) |wl_path| {
|
if (watchlist_path) |wl_path| {
|
||||||
const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null;
|
const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,30 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const Date = @import("date.zig").Date;
|
const Date = @import("date.zig").Date;
|
||||||
|
|
||||||
|
/// Type of holding in a portfolio lot.
|
||||||
|
pub const LotType = enum {
|
||||||
|
stock, // stocks and ETFs (default)
|
||||||
|
option, // option contracts
|
||||||
|
cd, // certificates of deposit
|
||||||
|
cash, // cash/money market
|
||||||
|
|
||||||
|
pub fn label(self: LotType) []const u8 {
|
||||||
|
return switch (self) {
|
||||||
|
.stock => "Stock",
|
||||||
|
.option => "Option",
|
||||||
|
.cd => "CD",
|
||||||
|
.cash => "Cash",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fromString(s: []const u8) LotType {
|
||||||
|
if (std.mem.eql(u8, s, "option")) return .option;
|
||||||
|
if (std.mem.eql(u8, s, "cd")) return .cd;
|
||||||
|
if (std.mem.eql(u8, s, "cash")) return .cash;
|
||||||
|
return .stock;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/// A single lot in a portfolio -- one purchase/sale event.
|
/// A single lot in a portfolio -- one purchase/sale event.
|
||||||
/// Open lots have no close_date/close_price.
|
/// Open lots have no close_date/close_price.
|
||||||
/// Closed lots have both.
|
/// Closed lots have both.
|
||||||
|
|
@ -15,6 +39,12 @@ pub const Lot = struct {
|
||||||
note: ?[]const u8 = null,
|
note: ?[]const u8 = null,
|
||||||
/// Optional account identifier (e.g. "Roth IRA", "Brokerage")
|
/// Optional account identifier (e.g. "Roth IRA", "Brokerage")
|
||||||
account: ?[]const u8 = null,
|
account: ?[]const u8 = null,
|
||||||
|
/// Type of holding (stock, option, cd, cash)
|
||||||
|
lot_type: LotType = .stock,
|
||||||
|
/// Maturity date (for CDs)
|
||||||
|
maturity_date: ?Date = null,
|
||||||
|
/// Interest rate (for CDs, as percentage e.g. 3.8 = 3.8%)
|
||||||
|
rate: ?f64 = null,
|
||||||
|
|
||||||
pub fn isOpen(self: Lot) bool {
|
pub fn isOpen(self: Lot) bool {
|
||||||
return self.close_date == null;
|
return self.close_date == null;
|
||||||
|
|
@ -75,7 +105,7 @@ pub const Portfolio = struct {
|
||||||
self.allocator.free(self.lots);
|
self.allocator.free(self.lots);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all unique symbols in the portfolio.
|
/// Get all unique symbols in the portfolio (all types).
|
||||||
pub fn symbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
|
pub fn symbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
|
||||||
var seen = std.StringHashMap(void).init(allocator);
|
var seen = std.StringHashMap(void).init(allocator);
|
||||||
defer seen.deinit();
|
defer seen.deinit();
|
||||||
|
|
@ -94,6 +124,27 @@ pub const Portfolio = struct {
|
||||||
return result.toOwnedSlice(allocator);
|
return result.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get unique symbols for stock/ETF lots only (skips options, CDs, cash).
|
||||||
|
pub fn stockSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
|
||||||
|
var seen = std.StringHashMap(void).init(allocator);
|
||||||
|
defer seen.deinit();
|
||||||
|
|
||||||
|
for (self.lots) |lot| {
|
||||||
|
if (lot.lot_type == .stock) {
|
||||||
|
try seen.put(lot.symbol, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = std.ArrayList([]const u8).empty;
|
||||||
|
errdefer result.deinit(allocator);
|
||||||
|
|
||||||
|
var iter = seen.keyIterator();
|
||||||
|
while (iter.next()) |key| {
|
||||||
|
try result.append(allocator, key.*);
|
||||||
|
}
|
||||||
|
return result.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all lots for a given symbol.
|
/// Get all lots for a given symbol.
|
||||||
pub fn lotsForSymbol(self: Portfolio, allocator: std.mem.Allocator, symbol: []const u8) ![]Lot {
|
pub fn lotsForSymbol(self: Portfolio, allocator: std.mem.Allocator, symbol: []const u8) ![]Lot {
|
||||||
var result = std.ArrayList(Lot).empty;
|
var result = std.ArrayList(Lot).empty;
|
||||||
|
|
@ -107,12 +158,26 @@ pub const Portfolio = struct {
|
||||||
return result.toOwnedSlice(allocator);
|
return result.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aggregate lots into positions by symbol.
|
/// Get all lots of a given security type (allocated copy).
|
||||||
|
pub fn lotsOfTypeAlloc(self: Portfolio, allocator: std.mem.Allocator, sec_type: LotType) ![]Lot {
|
||||||
|
var result = std.ArrayList(Lot).empty;
|
||||||
|
errdefer result.deinit(allocator);
|
||||||
|
|
||||||
|
for (self.lots) |lot| {
|
||||||
|
if (lot.lot_type == sec_type) {
|
||||||
|
try result.append(allocator, lot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate stock/ETF lots into positions by symbol (skips options, CDs, cash).
|
||||||
pub fn positions(self: Portfolio, allocator: std.mem.Allocator) ![]Position {
|
pub fn positions(self: Portfolio, allocator: std.mem.Allocator) ![]Position {
|
||||||
var map = std.StringHashMap(Position).init(allocator);
|
var map = std.StringHashMap(Position).init(allocator);
|
||||||
defer map.deinit();
|
defer map.deinit();
|
||||||
|
|
||||||
for (self.lots) |lot| {
|
for (self.lots) |lot| {
|
||||||
|
if (lot.lot_type != .stock) continue;
|
||||||
const entry = try map.getOrPut(lot.symbol);
|
const entry = try map.getOrPut(lot.symbol);
|
||||||
if (!entry.found_existing) {
|
if (!entry.found_existing) {
|
||||||
entry.value_ptr.* = .{
|
entry.value_ptr.* = .{
|
||||||
|
|
@ -153,23 +218,63 @@ pub const Portfolio = struct {
|
||||||
return result.toOwnedSlice(allocator);
|
return result.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total cost basis of all open lots.
|
/// Total cost basis of all open stock lots.
|
||||||
pub fn totalCostBasis(self: Portfolio) f64 {
|
pub fn totalCostBasis(self: Portfolio) f64 {
|
||||||
var total: f64 = 0;
|
var total: f64 = 0;
|
||||||
for (self.lots) |lot| {
|
for (self.lots) |lot| {
|
||||||
if (lot.isOpen()) total += lot.costBasis();
|
if (lot.isOpen() and lot.lot_type == .stock) total += lot.costBasis();
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total realized P&L from all closed lots.
|
/// Total realized P&L from all closed stock lots.
|
||||||
pub fn totalRealizedPnl(self: Portfolio) f64 {
|
pub fn totalRealizedPnl(self: Portfolio) f64 {
|
||||||
var total: f64 = 0;
|
var total: f64 = 0;
|
||||||
for (self.lots) |lot| {
|
for (self.lots) |lot| {
|
||||||
if (lot.realizedPnl()) |pnl| total += pnl;
|
if (lot.lot_type == .stock) {
|
||||||
|
if (lot.realizedPnl()) |pnl| total += pnl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Total cash across all accounts.
|
||||||
|
pub fn totalCash(self: Portfolio) f64 {
|
||||||
|
var total: f64 = 0;
|
||||||
|
for (self.lots) |lot| {
|
||||||
|
if (lot.lot_type == .cash) total += lot.shares;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total CD face value across all accounts.
|
||||||
|
pub fn totalCdFaceValue(self: Portfolio) f64 {
|
||||||
|
var total: f64 = 0;
|
||||||
|
for (self.lots) |lot| {
|
||||||
|
if (lot.lot_type == .cd) total += lot.shares;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total option cost basis (absolute value of shares * open_price).
|
||||||
|
pub fn totalOptionCost(self: Portfolio) f64 {
|
||||||
|
var total: f64 = 0;
|
||||||
|
for (self.lots) |lot| {
|
||||||
|
if (lot.lot_type == .option) {
|
||||||
|
// shares can be negative (short), open_price is per-contract cost
|
||||||
|
total += @abs(lot.shares) * lot.open_price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if portfolio has any lots of a given type.
|
||||||
|
pub fn hasType(self: Portfolio, sec_type: LotType) bool {
|
||||||
|
for (self.lots) |lot| {
|
||||||
|
if (lot.lot_type == sec_type) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
test "lot basics" {
|
test "lot basics" {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ pub const SectorWeight = @import("models/etf_profile.zig").SectorWeight;
|
||||||
pub const TickerInfo = @import("models/ticker_info.zig").TickerInfo;
|
pub const TickerInfo = @import("models/ticker_info.zig").TickerInfo;
|
||||||
pub const SecurityType = @import("models/ticker_info.zig").SecurityType;
|
pub const SecurityType = @import("models/ticker_info.zig").SecurityType;
|
||||||
pub const Lot = @import("models/portfolio.zig").Lot;
|
pub const Lot = @import("models/portfolio.zig").Lot;
|
||||||
|
pub const LotType = @import("models/portfolio.zig").LotType;
|
||||||
pub const Position = @import("models/portfolio.zig").Position;
|
pub const Position = @import("models/portfolio.zig").Position;
|
||||||
pub const Portfolio = @import("models/portfolio.zig").Portfolio;
|
pub const Portfolio = @import("models/portfolio.zig").Portfolio;
|
||||||
pub const Quote = @import("models/quote.zig").Quote;
|
pub const Quote = @import("models/quote.zig").Quote;
|
||||||
|
|
|
||||||
285
src/tui/main.zig
285
src/tui/main.zig
|
|
@ -55,7 +55,7 @@ const InputMode = enum {
|
||||||
help,
|
help,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A row in the portfolio view -- either a position header or an individual lot.
|
/// A row in the portfolio view -- position header, lot detail, or special sections.
|
||||||
const PortfolioRow = struct {
|
const PortfolioRow = struct {
|
||||||
kind: Kind,
|
kind: Kind,
|
||||||
symbol: []const u8,
|
symbol: []const u8,
|
||||||
|
|
@ -65,7 +65,7 @@ const PortfolioRow = struct {
|
||||||
/// Number of lots for this symbol (set on position rows)
|
/// Number of lots for this symbol (set on position rows)
|
||||||
lot_count: usize = 0,
|
lot_count: usize = 0,
|
||||||
|
|
||||||
const Kind = enum { position, lot, watchlist };
|
const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total };
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Styled line for rendering
|
/// Styled line for rendering
|
||||||
|
|
@ -123,8 +123,11 @@ const App = struct {
|
||||||
// Portfolio navigation
|
// Portfolio navigation
|
||||||
cursor: usize = 0, // selected row in portfolio view
|
cursor: usize = 0, // selected row in portfolio view
|
||||||
expanded: [64]bool = [_]bool{false} ** 64, // which positions are expanded
|
expanded: [64]bool = [_]bool{false} ** 64, // which positions are expanded
|
||||||
|
cash_expanded: bool = false, // whether cash section is expanded to show per-account
|
||||||
portfolio_rows: std.ArrayList(PortfolioRow) = .empty,
|
portfolio_rows: std.ArrayList(PortfolioRow) = .empty,
|
||||||
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_count: usize = 0, // total styled lines in portfolio view
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -262,11 +265,14 @@ 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;
|
||||||
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 row_idx = content_row - self.portfolio_header_lines;
|
const line_idx = content_row - self.portfolio_header_lines;
|
||||||
if (row_idx < self.portfolio_rows.items.len) {
|
if (line_idx < self.portfolio_line_count and line_idx < self.portfolio_line_to_row.len) {
|
||||||
self.cursor = row_idx;
|
const row_idx = self.portfolio_line_to_row[line_idx];
|
||||||
self.toggleExpand();
|
if (row_idx < self.portfolio_rows.items.len) {
|
||||||
return ctx.consumeAndRedraw();
|
self.cursor = row_idx;
|
||||||
|
self.toggleExpand();
|
||||||
|
return ctx.consumeAndRedraw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -541,7 +547,11 @@ const App = struct {
|
||||||
self.rebuildPortfolioRows();
|
self.rebuildPortfolioRows();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.lot => {},
|
.lot, .option_row, .cd_row, .cash_row, .section_header => {},
|
||||||
|
.cash_total => {
|
||||||
|
self.cash_expanded = !self.cash_expanded;
|
||||||
|
self.rebuildPortfolioRows();
|
||||||
|
},
|
||||||
.watchlist => {
|
.watchlist => {
|
||||||
self.setActiveSymbol(row.symbol);
|
self.setActiveSymbol(row.symbol);
|
||||||
self.active_tab = .quote;
|
self.active_tab = .quote;
|
||||||
|
|
@ -815,17 +825,22 @@ const App = struct {
|
||||||
var prices = std.StringHashMap(f64).init(self.allocator);
|
var prices = std.StringHashMap(f64).init(self.allocator);
|
||||||
defer prices.deinit();
|
defer prices.deinit();
|
||||||
|
|
||||||
const syms = pf.symbols(self.allocator) catch {
|
// Only fetch prices for stock/ETF symbols (skip options, CDs, cash)
|
||||||
|
const syms = pf.stockSymbols(self.allocator) catch {
|
||||||
self.setStatus("Error getting symbols");
|
self.setStatus("Error getting symbols");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
defer self.allocator.free(syms);
|
defer self.allocator.free(syms);
|
||||||
|
|
||||||
var latest_date: ?zfin.Date = null;
|
var latest_date: ?zfin.Date = null;
|
||||||
|
var fail_count: usize = 0;
|
||||||
for (syms) |sym| {
|
for (syms) |sym| {
|
||||||
// Try cache first; if miss, fetch (handles new securities / stale cache)
|
// Try cache first; if miss, fetch (handles new securities / stale cache)
|
||||||
const candles_slice = self.svc.getCachedCandles(sym) orelse blk: {
|
const candles_slice = self.svc.getCachedCandles(sym) orelse blk: {
|
||||||
const result = self.svc.getCandles(sym) catch break :blk null;
|
const result = self.svc.getCandles(sym) catch {
|
||||||
|
fail_count += 1;
|
||||||
|
break :blk null;
|
||||||
|
};
|
||||||
break :blk result.data;
|
break :blk result.data;
|
||||||
};
|
};
|
||||||
if (candles_slice) |cs| {
|
if (candles_slice) |cs| {
|
||||||
|
|
@ -850,6 +865,27 @@ const App = struct {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include non-stock assets in the grand total
|
||||||
|
// Cash and CDs add equally to value and cost (no gain/loss),
|
||||||
|
// options add at cost basis (no live pricing).
|
||||||
|
// This keeps unrealized_pnl correct (only stocks contribute market gains)
|
||||||
|
// but dilutes the return% against the full portfolio cost base.
|
||||||
|
const cash_total = pf.totalCash();
|
||||||
|
const cd_total = pf.totalCdFaceValue();
|
||||||
|
const opt_total = pf.totalOptionCost();
|
||||||
|
const non_stock = cash_total + cd_total + opt_total;
|
||||||
|
summary.total_value += non_stock;
|
||||||
|
summary.total_cost += non_stock;
|
||||||
|
if (summary.total_cost > 0) {
|
||||||
|
summary.unrealized_return = summary.unrealized_pnl / summary.total_cost;
|
||||||
|
}
|
||||||
|
// Reweight allocations against grand total
|
||||||
|
if (summary.total_value > 0) {
|
||||||
|
for (summary.allocations) |*a| {
|
||||||
|
a.weight = a.market_value / summary.total_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.portfolio_summary = summary;
|
self.portfolio_summary = summary;
|
||||||
self.rebuildPortfolioRows();
|
self.rebuildPortfolioRows();
|
||||||
|
|
||||||
|
|
@ -857,7 +893,14 @@ const App = struct {
|
||||||
self.setActiveSymbol(summary.allocations[0].symbol);
|
self.setActiveSymbol(summary.allocations[0].symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help");
|
// Show warning if any securities failed to load
|
||||||
|
if (fail_count > 0) {
|
||||||
|
var warn_buf: [128]u8 = undefined;
|
||||||
|
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed";
|
||||||
|
self.setStatus(warn_msg);
|
||||||
|
} else {
|
||||||
|
self.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rebuildPortfolioRows(self: *App) void {
|
fn rebuildPortfolioRows(self: *App) void {
|
||||||
|
|
@ -869,7 +912,7 @@ const App = struct {
|
||||||
var lcount: usize = 0;
|
var lcount: usize = 0;
|
||||||
if (self.portfolio) |pf| {
|
if (self.portfolio) |pf| {
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (std.mem.eql(u8, lot.symbol, a.symbol)) lcount += 1;
|
if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) lcount += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -887,7 +930,7 @@ const App = struct {
|
||||||
var matching: std.ArrayList(zfin.Lot) = .empty;
|
var matching: std.ArrayList(zfin.Lot) = .empty;
|
||||||
defer matching.deinit(self.allocator);
|
defer matching.deinit(self.allocator);
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (std.mem.eql(u8, lot.symbol, a.symbol)) {
|
if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) {
|
||||||
matching.append(self.allocator, lot) catch continue;
|
matching.append(self.allocator, lot) catch continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -924,6 +967,80 @@ const App = struct {
|
||||||
}) catch continue;
|
}) catch continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options section
|
||||||
|
if (self.portfolio) |pf| {
|
||||||
|
if (pf.hasType(.option)) {
|
||||||
|
self.portfolio_rows.append(self.allocator, .{
|
||||||
|
.kind = .section_header,
|
||||||
|
.symbol = "Options",
|
||||||
|
}) catch {};
|
||||||
|
for (pf.lots) |lot| {
|
||||||
|
if (lot.lot_type == .option) {
|
||||||
|
self.portfolio_rows.append(self.allocator, .{
|
||||||
|
.kind = .option_row,
|
||||||
|
.symbol = lot.symbol,
|
||||||
|
.lot = lot,
|
||||||
|
}) catch continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CDs section (sorted by maturity date, earliest first)
|
||||||
|
if (pf.hasType(.cd)) {
|
||||||
|
self.portfolio_rows.append(self.allocator, .{
|
||||||
|
.kind = .section_header,
|
||||||
|
.symbol = "Certificates of Deposit",
|
||||||
|
}) catch {};
|
||||||
|
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
|
||||||
|
defer cd_lots.deinit(self.allocator);
|
||||||
|
for (pf.lots) |lot| {
|
||||||
|
if (lot.lot_type == .cd) {
|
||||||
|
cd_lots.append(self.allocator, lot) catch continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std.mem.sort(zfin.Lot, cd_lots.items, {}, struct {
|
||||||
|
fn f(ctx: void, a: zfin.Lot, b: zfin.Lot) bool {
|
||||||
|
_ = ctx;
|
||||||
|
const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32);
|
||||||
|
const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32);
|
||||||
|
return ad < bd;
|
||||||
|
}
|
||||||
|
}.f);
|
||||||
|
for (cd_lots.items) |lot| {
|
||||||
|
self.portfolio_rows.append(self.allocator, .{
|
||||||
|
.kind = .cd_row,
|
||||||
|
.symbol = lot.symbol,
|
||||||
|
.lot = lot,
|
||||||
|
}) catch continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cash section (single total row, expandable to show per-account)
|
||||||
|
if (pf.hasType(.cash)) {
|
||||||
|
self.portfolio_rows.append(self.allocator, .{
|
||||||
|
.kind = .section_header,
|
||||||
|
.symbol = "Cash",
|
||||||
|
}) catch {};
|
||||||
|
// Total cash row
|
||||||
|
self.portfolio_rows.append(self.allocator, .{
|
||||||
|
.kind = .cash_total,
|
||||||
|
.symbol = "CASH",
|
||||||
|
}) catch {};
|
||||||
|
// Per-account cash rows (expanded when cash_total is toggled)
|
||||||
|
if (self.cash_expanded) {
|
||||||
|
for (pf.lots) |lot| {
|
||||||
|
if (lot.lot_type == .cash) {
|
||||||
|
self.portfolio_rows.append(self.allocator, .{
|
||||||
|
.kind = .cash_row,
|
||||||
|
.symbol = lot.account orelse "Unknown",
|
||||||
|
.lot = lot,
|
||||||
|
}) catch continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn loadPerfData(self: *App) void {
|
fn loadPerfData(self: *App) void {
|
||||||
|
|
@ -1349,9 +1466,11 @@ const App = struct {
|
||||||
|
|
||||||
// Track header line count for mouse click mapping (after all header lines)
|
// Track header line count for mouse click mapping (after all header lines)
|
||||||
self.portfolio_header_lines = lines.items.len;
|
self.portfolio_header_lines = lines.items.len;
|
||||||
|
self.portfolio_line_count = 0;
|
||||||
|
|
||||||
// Data rows
|
// Data rows
|
||||||
for (self.portfolio_rows.items, 0..) |row, ri| {
|
for (self.portfolio_rows.items, 0..) |row, ri| {
|
||||||
|
const lines_before = lines.items.len;
|
||||||
const is_cursor = ri == self.cursor;
|
const is_cursor = ri == self.cursor;
|
||||||
const is_active_sym = std.mem.eql(u8, row.symbol, self.symbol);
|
const is_active_sym = std.mem.eql(u8, row.symbol, self.symbol);
|
||||||
switch (row.kind) {
|
switch (row.kind) {
|
||||||
|
|
@ -1381,21 +1500,48 @@ const App = struct {
|
||||||
|
|
||||||
// Date + ST/LT: show for single-lot, blank for multi-lot
|
// Date + ST/LT: show for single-lot, blank for multi-lot
|
||||||
var pos_date_buf: [10]u8 = undefined;
|
var pos_date_buf: [10]u8 = undefined;
|
||||||
const date_col: []const u8 = if (!is_multi) blk: {
|
var date_col: []const u8 = "";
|
||||||
|
var acct_col: []const u8 = "";
|
||||||
|
if (!is_multi) {
|
||||||
if (self.portfolio) |pf| {
|
if (self.portfolio) |pf| {
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (std.mem.eql(u8, lot.symbol, a.symbol)) {
|
if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) {
|
||||||
const ds = lot.open_date.format(&pos_date_buf);
|
const ds = lot.open_date.format(&pos_date_buf);
|
||||||
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
||||||
break :blk std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
|
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
|
||||||
|
acct_col = lot.account orelse "";
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break :blk "";
|
} else {
|
||||||
} else "";
|
// Multi-lot: show account if all lots share the same one
|
||||||
|
if (self.portfolio) |pf| {
|
||||||
|
var common_acct: ?[]const u8 = null;
|
||||||
|
var mixed = false;
|
||||||
|
for (pf.lots) |lot| {
|
||||||
|
if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) {
|
||||||
|
if (common_acct) |ca| {
|
||||||
|
const la = lot.account orelse "";
|
||||||
|
if (!std.mem.eql(u8, ca, la)) {
|
||||||
|
mixed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
common_acct = lot.account orelse "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mixed) {
|
||||||
|
acct_col = common_acct orelse "";
|
||||||
|
} else {
|
||||||
|
acct_col = "Multiple";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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}", .{
|
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}", .{
|
||||||
arrow, star, a.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col,
|
arrow, star, a.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, acct_col,
|
||||||
});
|
});
|
||||||
|
|
||||||
// base: neutral text for main cols, green/red only for gain/loss col
|
// base: neutral text for main cols, green/red only for gain/loss col
|
||||||
|
|
@ -1472,7 +1618,106 @@ const App = struct {
|
||||||
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||||
try lines.append(arena, .{ .text = text, .style = row_style });
|
try lines.append(arena, .{ .text = text, .style = row_style });
|
||||||
},
|
},
|
||||||
|
.section_header => {
|
||||||
|
// Blank line before section header
|
||||||
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
const hdr_text = try std.fmt.allocPrint(arena, " {s}", .{row.symbol});
|
||||||
|
const hdr_style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
||||||
|
try lines.append(arena, .{ .text = hdr_text, .style = hdr_style });
|
||||||
|
// Add column headers for each section type
|
||||||
|
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}", .{
|
||||||
|
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
|
||||||
|
});
|
||||||
|
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
|
||||||
|
} 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}", .{
|
||||||
|
"CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account",
|
||||||
|
});
|
||||||
|
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.option_row => {
|
||||||
|
if (row.lot) |lot| {
|
||||||
|
// Options: symbol (description), qty (contracts), cost/contract, cost basis, account
|
||||||
|
const qty = lot.shares; // negative = short
|
||||||
|
const cost_per = lot.open_price; // per-contract cost
|
||||||
|
const total_cost = @abs(qty) * cost_per;
|
||||||
|
var cost_buf3: [24]u8 = undefined;
|
||||||
|
var total_buf: [24]u8 = undefined;
|
||||||
|
const acct_col2: []const u8 = lot.account orelse "";
|
||||||
|
const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{
|
||||||
|
lot.symbol,
|
||||||
|
qty,
|
||||||
|
fmt.fmtMoney2(&cost_buf3, cost_per),
|
||||||
|
fmt.fmtMoney(&total_buf, total_cost),
|
||||||
|
acct_col2,
|
||||||
|
});
|
||||||
|
const row_style2 = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||||
|
try lines.append(arena, .{ .text = text, .style = row_style2 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.cd_row => {
|
||||||
|
if (row.lot) |lot| {
|
||||||
|
// CDs: symbol (CUSIP), face value, rate%, maturity date, note, account
|
||||||
|
var face_buf: [24]u8 = undefined;
|
||||||
|
var mat_buf: [10]u8 = undefined;
|
||||||
|
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
|
||||||
|
var rate_str_buf: [10]u8 = undefined;
|
||||||
|
const rate_str: []const u8 = if (lot.rate) |r|
|
||||||
|
std.fmt.bufPrint(&rate_str_buf, "{d:.2}%", .{r}) catch "--"
|
||||||
|
else
|
||||||
|
"--";
|
||||||
|
const note_str: []const u8 = lot.note orelse "";
|
||||||
|
// Truncate note to 40 chars for display
|
||||||
|
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
|
||||||
|
const acct_col3: []const u8 = lot.account orelse "";
|
||||||
|
const text = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
|
||||||
|
lot.symbol,
|
||||||
|
fmt.fmtMoney(&face_buf, lot.shares),
|
||||||
|
rate_str,
|
||||||
|
mat_str,
|
||||||
|
note_display,
|
||||||
|
acct_col3,
|
||||||
|
});
|
||||||
|
const row_style3 = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||||
|
try lines.append(arena, .{ .text = text, .style = row_style3 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.cash_total => {
|
||||||
|
if (self.portfolio) |pf| {
|
||||||
|
const total_cash = pf.totalCash();
|
||||||
|
var cash_buf: [24]u8 = undefined;
|
||||||
|
const arrow3: []const u8 = if (self.cash_expanded) "v " else "> ";
|
||||||
|
const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{
|
||||||
|
arrow3,
|
||||||
|
fmt.fmtMoney(&cash_buf, total_cash),
|
||||||
|
});
|
||||||
|
const row_style4 = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||||
|
try lines.append(arena, .{ .text = text, .style = row_style4 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.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),
|
||||||
|
});
|
||||||
|
const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
||||||
|
try lines.append(arena, .{ .text = text, .style = row_style5 });
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
// Map all styled lines produced by this row back to the row index
|
||||||
|
const lines_after = lines.items.len;
|
||||||
|
for (lines_before..lines_after) |li| {
|
||||||
|
const map_idx = li - self.portfolio_header_lines;
|
||||||
|
if (map_idx < self.portfolio_line_to_row.len) {
|
||||||
|
self.portfolio_line_to_row[map_idx] = ri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.portfolio_line_count = lines_after - self.portfolio_header_lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render
|
// Render
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue