997 lines
42 KiB
Zig
997 lines
42 KiB
Zig
const std = @import("std");
|
|
const zfin = @import("../root.zig");
|
|
const cli = @import("common.zig");
|
|
const fmt = cli.fmt;
|
|
|
|
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, 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
|
|
const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| {
|
|
try cli.stderrPrint("Error reading portfolio file: ");
|
|
try cli.stderrPrint(@errorName(err));
|
|
try cli.stderrPrint("\n");
|
|
return;
|
|
};
|
|
defer allocator.free(data);
|
|
|
|
var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
|
|
try cli.stderrPrint("Error parsing portfolio file.\n");
|
|
return;
|
|
};
|
|
defer portfolio.deinit();
|
|
|
|
if (portfolio.lots.len == 0) {
|
|
try cli.stderrPrint("Portfolio is empty.\n");
|
|
return;
|
|
}
|
|
|
|
// Get stock/ETF positions (excludes options, CDs, cash)
|
|
const positions = try portfolio.positions(allocator);
|
|
defer allocator.free(positions);
|
|
|
|
// Get unique stock/ETF symbols and fetch current prices
|
|
const syms = try portfolio.stockSymbols(allocator);
|
|
defer allocator.free(syms);
|
|
|
|
var prices = std.StringHashMap(f64).init(allocator);
|
|
defer prices.deinit();
|
|
|
|
var fail_count: usize = 0;
|
|
|
|
// Also collect watch symbols that need fetching
|
|
var watch_syms: std.ArrayList([]const u8) = .empty;
|
|
defer watch_syms.deinit(allocator);
|
|
{
|
|
var seen = std.StringHashMap(void).init(allocator);
|
|
defer seen.deinit();
|
|
for (syms) |s| try seen.put(s, {});
|
|
for (portfolio.lots) |lot| {
|
|
if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) {
|
|
try seen.put(lot.priceSymbol(), {});
|
|
try watch_syms.append(allocator, lot.priceSymbol());
|
|
}
|
|
}
|
|
}
|
|
|
|
// All symbols to fetch (stock positions + watch)
|
|
const all_syms_count = syms.len + watch_syms.items.len;
|
|
|
|
if (all_syms_count > 0) {
|
|
if (config.twelvedata_key == null) {
|
|
try cli.stderrPrint("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n");
|
|
}
|
|
|
|
// Progress callback for per-symbol output
|
|
var progress_ctx = cli.LoadProgress{
|
|
.svc = svc,
|
|
.color = color,
|
|
.index_offset = 0,
|
|
.grand_total = all_syms_count,
|
|
};
|
|
|
|
// Load prices for stock/ETF positions
|
|
const load_result = svc.loadPrices(syms, &prices, force_refresh, progress_ctx.callback());
|
|
fail_count = load_result.fail_count;
|
|
|
|
// Fetch watch symbol candles (for watchlist display, not portfolio value)
|
|
progress_ctx.index_offset = syms.len;
|
|
_ = svc.loadPrices(watch_syms.items, &prices, force_refresh, progress_ctx.callback());
|
|
|
|
// Summary line
|
|
{
|
|
const cached_count = load_result.cached_count;
|
|
const fetched_count = load_result.fetched_count;
|
|
var msg_buf: [256]u8 = undefined;
|
|
if (cached_count == all_syms_count) {
|
|
const msg = std.fmt.bufPrint(&msg_buf, "All {d} symbols loaded from cache\n", .{all_syms_count}) catch "Loaded from cache\n";
|
|
try cli.stderrPrint(msg);
|
|
} else if (fail_count > 0) {
|
|
const stale = load_result.stale_count;
|
|
if (stale > 0) {
|
|
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed — {d} using stale cache)\n", .{ all_syms_count, cached_count, fetched_count, fail_count, stale }) catch "Done loading\n";
|
|
try cli.stderrPrint(msg);
|
|
} else {
|
|
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ all_syms_count, cached_count, fetched_count, fail_count }) catch "Done loading\n";
|
|
try cli.stderrPrint(msg);
|
|
}
|
|
} else {
|
|
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ all_syms_count, cached_count, fetched_count }) catch "Done loading\n";
|
|
try cli.stderrPrint(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute summary
|
|
// Build fallback prices for symbols that failed API fetch
|
|
var manual_price_set = try zfin.risk.buildFallbackPrices(allocator, portfolio.lots, positions, &prices);
|
|
defer manual_price_set.deinit();
|
|
|
|
var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch {
|
|
try cli.stderrPrint("Error computing portfolio summary.\n");
|
|
return;
|
|
};
|
|
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.display_symbol, b.display_symbol);
|
|
}
|
|
}.f);
|
|
|
|
// Include non-stock assets in the grand total
|
|
summary.adjustForNonStockAssets(portfolio);
|
|
|
|
// Build candle map once for historical snapshots and risk metrics.
|
|
// This avoids parsing the full candle history multiple times.
|
|
var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
|
|
defer {
|
|
var it = candle_map.valueIterator();
|
|
while (it.next()) |v| allocator.free(v.*);
|
|
candle_map.deinit();
|
|
}
|
|
{
|
|
const stock_syms = try portfolio.stockSymbols(allocator);
|
|
defer allocator.free(stock_syms);
|
|
for (stock_syms) |sym| {
|
|
if (svc.getCachedCandles(sym)) |cs| {
|
|
try candle_map.put(sym, cs);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect watch symbols and their prices for display.
|
|
// Includes watch lots from portfolio + symbols from separate watchlist file.
|
|
var watch_list: std.ArrayList([]const u8) = .empty;
|
|
defer watch_list.deinit(allocator);
|
|
var watch_prices = std.StringHashMap(f64).init(allocator);
|
|
defer watch_prices.deinit();
|
|
{
|
|
var watch_seen = std.StringHashMap(void).init(allocator);
|
|
defer watch_seen.deinit();
|
|
// Exclude portfolio position symbols from watchlist
|
|
for (summary.allocations) |a| {
|
|
try watch_seen.put(a.symbol, {});
|
|
}
|
|
|
|
// Watch lots from portfolio
|
|
for (portfolio.lots) |lot| {
|
|
if (lot.security_type == .watch) {
|
|
const sym = lot.priceSymbol();
|
|
if (watch_seen.contains(sym)) continue;
|
|
try watch_seen.put(sym, {});
|
|
try watch_list.append(allocator, sym);
|
|
if (svc.getCachedLastClose(sym)) |close| {
|
|
try watch_prices.put(sym, close);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Separate watchlist file (backward compat)
|
|
if (watchlist_path) |wl_path| {
|
|
const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null;
|
|
if (wl_data) |wd| {
|
|
defer allocator.free(wd);
|
|
var wl_lines = std.mem.splitScalar(u8, wd, '\n');
|
|
while (wl_lines.next()) |line| {
|
|
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
|
|
if (trimmed.len == 0 or trimmed[0] == '#') continue;
|
|
if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| {
|
|
const rest = trimmed[idx + "symbol::".len ..];
|
|
const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len;
|
|
const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace);
|
|
if (sym.len > 0 and sym.len <= 10) {
|
|
if (watch_seen.contains(sym)) continue;
|
|
try watch_seen.put(sym, {});
|
|
try watch_list.append(allocator, sym);
|
|
if (svc.getCachedLastClose(sym)) |close| {
|
|
try watch_prices.put(sym, close);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try display(
|
|
allocator,
|
|
out,
|
|
color,
|
|
file_path,
|
|
&portfolio,
|
|
positions,
|
|
&summary,
|
|
prices,
|
|
candle_map,
|
|
watch_list.items,
|
|
watch_prices,
|
|
);
|
|
}
|
|
|
|
/// Render the full portfolio display. All data is pre-fetched; no service calls.
|
|
pub fn display(
|
|
allocator: std.mem.Allocator,
|
|
out: *std.Io.Writer,
|
|
color: bool,
|
|
file_path: []const u8,
|
|
portfolio: *const zfin.Portfolio,
|
|
positions: []const zfin.Position,
|
|
summary: *const zfin.risk.PortfolioSummary,
|
|
prices: std.StringHashMap(f64),
|
|
candle_map: std.StringHashMap([]const zfin.Candle),
|
|
watch_symbols: []const []const u8,
|
|
watch_prices: std.StringHashMap(f64),
|
|
) !void {
|
|
// Header with summary
|
|
try cli.setBold(out, color);
|
|
try out.print("\nPortfolio Summary ({s})\n", .{file_path});
|
|
try cli.reset(out, color);
|
|
try out.print("========================================\n", .{});
|
|
|
|
// Summary bar
|
|
{
|
|
var val_buf: [24]u8 = undefined;
|
|
var cost_buf: [24]u8 = undefined;
|
|
var gl_buf: [24]u8 = undefined;
|
|
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
|
|
try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoneyAbs(&val_buf, summary.total_value), fmt.fmtMoneyAbs(&cost_buf, summary.total_cost) });
|
|
try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
|
|
if (summary.unrealized_gain_loss >= 0) {
|
|
try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
|
|
} else {
|
|
try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
|
|
}
|
|
try cli.reset(out, color);
|
|
try out.print("\n", .{});
|
|
}
|
|
|
|
// Lot counts (stocks/ETFs only)
|
|
var open_lots: u32 = 0;
|
|
var closed_lots: u32 = 0;
|
|
for (portfolio.lots) |lot| {
|
|
if (lot.security_type != .stock) continue;
|
|
if (lot.isOpen()) open_lots += 1 else closed_lots += 1;
|
|
}
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len });
|
|
try cli.reset(out, color);
|
|
|
|
// Historical portfolio value snapshots
|
|
{
|
|
if (candle_map.count() > 0) {
|
|
const snapshots = zfin.risk.computeHistoricalSnapshots(
|
|
fmt.todayDate(),
|
|
positions,
|
|
prices,
|
|
candle_map,
|
|
);
|
|
try out.print(" Historical: ", .{});
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| {
|
|
const snap = snapshots[pi];
|
|
var hbuf: [16]u8 = undefined;
|
|
const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct());
|
|
if (snap.position_count > 0) try cli.setGainLoss(out, color, snap.changePct());
|
|
try out.print(" {s}: {s}", .{ period.label(), change_str });
|
|
if (pi < zfin.risk.HistoricalPeriod.all.len - 1) try out.print(" ", .{});
|
|
}
|
|
try cli.reset(out, color);
|
|
try out.print("\n", .{});
|
|
}
|
|
}
|
|
|
|
// Column headers
|
|
try out.print("\n", .{});
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" " ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{
|
|
"Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account",
|
|
});
|
|
try out.print(" " ++ 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 cli.reset(out, color);
|
|
|
|
// Position rows with lot detail
|
|
for (summary.allocations) |a| {
|
|
// Count stock lots for this symbol
|
|
var lots_for_sym: std.ArrayList(zfin.Lot) = .empty;
|
|
defer lots_for_sym.deinit(allocator);
|
|
for (portfolio.lots) |lot| {
|
|
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
|
try lots_for_sym.append(allocator, lot);
|
|
}
|
|
}
|
|
std.mem.sort(zfin.Lot, lots_for_sym.items, {}, fmt.lotSortFn);
|
|
const is_multi = lots_for_sym.items.len > 1;
|
|
|
|
// Position summary row
|
|
{
|
|
var mv_buf: [24]u8 = undefined;
|
|
var cost_buf2: [24]u8 = undefined;
|
|
var price_buf2: [24]u8 = undefined;
|
|
var gl_val_buf: [24]u8 = undefined;
|
|
const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss;
|
|
const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs);
|
|
const sign: []const u8 = if (a.unrealized_gain_loss >= 0) "+" else "-";
|
|
|
|
// Date + ST/LT for single-lot positions
|
|
var date_col: [24]u8 = .{' '} ** 24;
|
|
var date_col_len: usize = 0;
|
|
if (!is_multi and lots_for_sym.items.len == 1) {
|
|
const lot = lots_for_sym.items[0];
|
|
var pos_date_buf: [10]u8 = undefined;
|
|
const ds = lot.open_date.format(&pos_date_buf);
|
|
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
|
const written = std.fmt.bufPrint(&date_col, "{s} {s}", .{ ds, indicator }) catch "";
|
|
date_col_len = written.len;
|
|
}
|
|
|
|
if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING);
|
|
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{
|
|
a.display_symbol, a.shares, fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost),
|
|
});
|
|
try out.print("{s:>10}", .{fmt.fmtMoneyAbs(&price_buf2, a.current_price)});
|
|
try out.print(" {s:>16} ", .{fmt.fmtMoneyAbs(&mv_buf, a.market_value)});
|
|
try cli.setGainLoss(out, color, a.unrealized_gain_loss);
|
|
try out.print("{s}{s:>13}", .{ sign, gl_money });
|
|
if (a.is_manual_price) {
|
|
try cli.setFg(out, color, cli.CLR_WARNING);
|
|
} else {
|
|
try cli.reset(out, color);
|
|
}
|
|
try out.print(" {d:>7.1}%", .{a.weight * 100.0});
|
|
if (date_col_len > 0) {
|
|
try out.print(" {s}", .{date_col[0..date_col_len]});
|
|
}
|
|
// Account for single-lot
|
|
if (!is_multi and lots_for_sym.items.len == 1) {
|
|
if (lots_for_sym.items[0].account) |acct| {
|
|
try out.print(" {s}", .{acct});
|
|
}
|
|
}
|
|
if (a.is_manual_price) try cli.reset(out, color);
|
|
try out.print("\n", .{});
|
|
}
|
|
|
|
// Lot detail rows (always expanded for CLI)
|
|
if (is_multi) {
|
|
// Check if any lots are DRIP
|
|
var has_drip = false;
|
|
for (lots_for_sym.items) |lot| {
|
|
if (lot.drip) {
|
|
has_drip = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!has_drip) {
|
|
// No DRIP: show all individually
|
|
for (lots_for_sym.items) |lot| {
|
|
try printLotRow(out, color, lot, a.current_price);
|
|
}
|
|
} else {
|
|
// Show non-DRIP lots individually
|
|
for (lots_for_sym.items) |lot| {
|
|
if (!lot.drip) {
|
|
try printLotRow(out, color, lot, a.current_price);
|
|
}
|
|
}
|
|
|
|
// Summarize DRIP lots as ST/LT
|
|
const drip = fmt.aggregateDripLots(lots_for_sym.items);
|
|
|
|
if (!drip.st.isEmpty()) {
|
|
var avg_buf: [24]u8 = undefined;
|
|
var d1_buf: [10]u8 = undefined;
|
|
var d2_buf: [10]u8 = undefined;
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
|
|
drip.st.lot_count,
|
|
drip.st.shares,
|
|
fmt.fmtMoneyAbs(&avg_buf, drip.st.avgCost()),
|
|
if (drip.st.first_date) |d| d.format(&d1_buf)[0..7] else "?",
|
|
if (drip.st.last_date) |d| d.format(&d2_buf)[0..7] else "?",
|
|
});
|
|
try cli.reset(out, color);
|
|
}
|
|
if (!drip.lt.isEmpty()) {
|
|
var avg_buf2: [24]u8 = undefined;
|
|
var d1_buf2: [10]u8 = undefined;
|
|
var d2_buf2: [10]u8 = undefined;
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" LT: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
|
|
drip.lt.lot_count,
|
|
drip.lt.shares,
|
|
fmt.fmtMoneyAbs(&avg_buf2, drip.lt.avgCost()),
|
|
if (drip.lt.first_date) |d| d.format(&d1_buf2)[0..7] else "?",
|
|
if (drip.lt.last_date) |d| d.format(&d2_buf2)[0..7] else "?",
|
|
});
|
|
try cli.reset(out, color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Totals line
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{
|
|
"", "", "", "", "", "", "",
|
|
});
|
|
try cli.reset(out, color);
|
|
{
|
|
var total_mv_buf: [24]u8 = undefined;
|
|
var total_gl_buf: [24]u8 = undefined;
|
|
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
|
|
try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {s:>16} ", .{
|
|
"", "", "", "TOTAL", fmt.fmtMoneyAbs(&total_mv_buf, summary.total_value),
|
|
});
|
|
try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
|
|
if (summary.unrealized_gain_loss >= 0) {
|
|
try out.print("+{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
|
|
} else {
|
|
try out.print("-{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
|
|
}
|
|
try cli.reset(out, color);
|
|
try out.print(" {s:>7}\n", .{"100.0%"});
|
|
}
|
|
|
|
if (summary.realized_gain_loss != 0) {
|
|
var rpl_buf: [24]u8 = undefined;
|
|
const rpl_abs = if (summary.realized_gain_loss >= 0) summary.realized_gain_loss else -summary.realized_gain_loss;
|
|
try cli.setGainLoss(out, color, summary.realized_gain_loss);
|
|
if (summary.realized_gain_loss >= 0) {
|
|
try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
|
|
} else {
|
|
try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
|
|
}
|
|
try cli.reset(out, color);
|
|
}
|
|
|
|
// Options section
|
|
if (portfolio.hasType(.option)) {
|
|
try out.print("\n", .{});
|
|
try cli.setBold(out, color);
|
|
try out.print(" Options\n", .{});
|
|
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}\n", .{
|
|
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
|
|
});
|
|
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14} {s:->10}\n", .{
|
|
"", "", "", "", "",
|
|
});
|
|
try cli.reset(out, color);
|
|
|
|
var opt_total_cost: f64 = 0;
|
|
for (portfolio.lots) |lot| {
|
|
if (lot.security_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.fmtMoneyAbs(&cost_per_buf, cost_per),
|
|
fmt.fmtMoneyAbs(&total_cost_buf, total_cost_opt),
|
|
acct,
|
|
});
|
|
}
|
|
// 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
|
|
if (portfolio.hasType(.cd)) {
|
|
try out.print("\n", .{});
|
|
try cli.setBold(out, color);
|
|
try out.print(" Certificates of Deposit\n", .{});
|
|
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}\n", .{
|
|
"CUSIP", "Face Value", "Rate", "Maturity", "Description",
|
|
});
|
|
try out.print(" {s:->12} {s:->14} {s:->7} {s:->10} {s:->30}\n", .{
|
|
"", "", "", "", "",
|
|
});
|
|
try cli.reset(out, color);
|
|
|
|
// Collect and sort CDs by maturity date (earliest first)
|
|
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
|
|
defer cd_lots.deinit(allocator);
|
|
for (portfolio.lots) |lot| {
|
|
if (lot.security_type == .cd) {
|
|
try cd_lots.append(allocator, lot);
|
|
}
|
|
}
|
|
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
|
|
|
|
var cd_section_total: f64 = 0;
|
|
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.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
|
|
if (portfolio.hasType(.cash)) {
|
|
try out.print("\n", .{});
|
|
try cli.setBold(out, color);
|
|
try out.print(" Cash\n", .{});
|
|
try cli.reset(out, color);
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
var cash_hdr_buf: [80]u8 = undefined;
|
|
try out.print("{s}\n", .{fmt.fmtCashHeader(&cash_hdr_buf)});
|
|
var cash_sep_buf: [80]u8 = undefined;
|
|
try out.print("{s}\n", .{fmt.fmtCashSep(&cash_sep_buf)});
|
|
try cli.reset(out, color);
|
|
|
|
for (portfolio.lots) |lot| {
|
|
if (lot.security_type != .cash) continue;
|
|
const acct2: []const u8 = lot.account orelse "Unknown";
|
|
var row_buf: [160]u8 = undefined;
|
|
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares, lot.note)});
|
|
}
|
|
// Cash total
|
|
var sep_buf: [80]u8 = undefined;
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print("{s}\n", .{fmt.fmtCashSep(&sep_buf)});
|
|
try cli.reset(out, color);
|
|
var total_buf: [80]u8 = undefined;
|
|
try cli.setBold(out, color);
|
|
try out.print("{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash())});
|
|
try cli.reset(out, color);
|
|
}
|
|
|
|
// Illiquid assets section
|
|
if (portfolio.hasType(.illiquid)) {
|
|
try out.print("\n", .{});
|
|
try cli.setBold(out, color);
|
|
try out.print(" Illiquid Assets\n", .{});
|
|
try cli.reset(out, color);
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
var il_hdr_buf: [80]u8 = undefined;
|
|
try out.print("{s}\n", .{fmt.fmtIlliquidHeader(&il_hdr_buf)});
|
|
var il_sep_buf1: [80]u8 = undefined;
|
|
try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf1)});
|
|
try cli.reset(out, color);
|
|
|
|
for (portfolio.lots) |lot| {
|
|
if (lot.security_type != .illiquid) continue;
|
|
var il_row_buf: [160]u8 = undefined;
|
|
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.symbol, lot.shares, lot.note)});
|
|
}
|
|
// Illiquid total
|
|
var il_sep_buf2: [80]u8 = undefined;
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf2)});
|
|
try cli.reset(out, color);
|
|
var il_total_buf: [80]u8 = undefined;
|
|
try cli.setBold(out, color);
|
|
try out.print("{s}\n", .{fmt.fmtIlliquidTotal(&il_total_buf, portfolio.totalIlliquid())});
|
|
try cli.reset(out, color);
|
|
}
|
|
|
|
// Net Worth (if illiquid assets exist)
|
|
if (portfolio.hasType(.illiquid)) {
|
|
const illiquid_total = portfolio.totalIlliquid();
|
|
const net_worth = summary.total_value + illiquid_total;
|
|
var nw_buf: [24]u8 = undefined;
|
|
var liq_buf: [24]u8 = undefined;
|
|
var il_buf: [24]u8 = undefined;
|
|
try out.print("\n", .{});
|
|
try cli.setBold(out, color);
|
|
try out.print(" Net Worth: {s} (Liquid: {s} Illiquid: {s})\n", .{
|
|
fmt.fmtMoneyAbs(&nw_buf, net_worth),
|
|
fmt.fmtMoneyAbs(&liq_buf, summary.total_value),
|
|
fmt.fmtMoneyAbs(&il_buf, illiquid_total),
|
|
});
|
|
try cli.reset(out, color);
|
|
}
|
|
|
|
// Watchlist
|
|
if (watch_symbols.len > 0) {
|
|
try out.print("\n", .{});
|
|
try cli.setBold(out, color);
|
|
try out.print(" Watchlist:\n", .{});
|
|
try cli.reset(out, color);
|
|
for (watch_symbols) |sym| {
|
|
var price_str: [16]u8 = undefined;
|
|
const ps: []const u8 = if (watch_prices.get(sym)) |close|
|
|
fmt.fmtMoneyAbs(&price_str, close)
|
|
else
|
|
"--";
|
|
try out.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps });
|
|
}
|
|
}
|
|
|
|
// Risk metrics
|
|
{
|
|
var any_risk = false;
|
|
|
|
for (summary.allocations) |a| {
|
|
if (candle_map.get(a.symbol)) |candles| {
|
|
if (zfin.risk.computeRisk(candles, zfin.risk.default_risk_free_rate)) |metrics| {
|
|
if (!any_risk) {
|
|
try out.print("\n", .{});
|
|
try cli.setBold(out, color);
|
|
try out.print(" Risk Metrics (from cached price data):\n", .{});
|
|
try cli.reset(out, color);
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" {s:>6} {s:>10} {s:>8} {s:>10}\n", .{
|
|
"Symbol", "Volatility", "Sharpe", "Max DD",
|
|
});
|
|
try out.print(" {s:->6} {s:->10} {s:->8} {s:->10}\n", .{
|
|
"", "", "", "",
|
|
});
|
|
try cli.reset(out, color);
|
|
any_risk = true;
|
|
}
|
|
try out.print(" {s:>6} {d:>9.1}% {d:>8.2} ", .{
|
|
a.symbol, metrics.volatility * 100.0, metrics.sharpe,
|
|
});
|
|
try cli.setFg(out, color, cli.CLR_NEGATIVE);
|
|
try out.print("{d:>9.1}%", .{metrics.max_drawdown * 100.0});
|
|
try cli.reset(out, color);
|
|
if (metrics.drawdown_trough) |dt| {
|
|
var db: [10]u8 = undefined;
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" (trough {s})", .{dt.format(&db)});
|
|
try cli.reset(out, color);
|
|
}
|
|
try out.print("\n", .{});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try out.print("\n", .{});
|
|
}
|
|
|
|
pub fn printLotRow(out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64) !void {
|
|
var lot_price_buf: [24]u8 = undefined;
|
|
var lot_date_buf: [10]u8 = undefined;
|
|
const date_str = lot.open_date.format(&lot_date_buf);
|
|
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
|
const status_str: []const u8 = if (lot.isOpen()) "open" else "closed";
|
|
const acct_col: []const u8 = lot.account orelse "";
|
|
|
|
const use_price = lot.close_price orelse current_price;
|
|
const gl = lot.shares * (use_price - lot.open_price);
|
|
var lot_gl_buf: [24]u8 = undefined;
|
|
const lot_gl_abs = if (gl >= 0) gl else -gl;
|
|
const lot_gl_money = fmt.fmtMoneyAbs(&lot_gl_buf, lot_gl_abs);
|
|
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
|
|
|
|
var lot_mv_buf: [24]u8 = undefined;
|
|
const lot_mv = fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price);
|
|
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{
|
|
status_str, lot.shares, fmt.fmtMoneyAbs(&lot_price_buf, lot.open_price), "", lot_mv,
|
|
});
|
|
try cli.reset(out, color);
|
|
try cli.setGainLoss(out, color, gl);
|
|
try out.print("{s}{s:>13}", .{ lot_sign, lot_gl_money });
|
|
try cli.reset(out, color);
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" {s:>8} {s} {s} {s}\n", .{ "", date_str, indicator, acct_col });
|
|
try cli.reset(out, color);
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
const testing = std.testing;
|
|
|
|
/// Helper: build a minimal portfolio for testing.
|
|
/// Returns lots as a stack-allocated array and a Portfolio that references them.
|
|
/// Caller must NOT call deinit() since lots are stack-allocated.
|
|
fn testPortfolio(lots: []const zfin.Lot) zfin.Portfolio {
|
|
return .{
|
|
.lots = @constCast(lots),
|
|
.allocator = testing.allocator,
|
|
};
|
|
}
|
|
|
|
fn testSummary(allocations: []zfin.risk.Allocation) zfin.risk.PortfolioSummary {
|
|
var total_value: f64 = 0;
|
|
var total_cost: f64 = 0;
|
|
var unrealized_gain_loss: f64 = 0;
|
|
for (allocations) |a| {
|
|
total_value += a.market_value;
|
|
total_cost += a.cost_basis;
|
|
unrealized_gain_loss += a.unrealized_gain_loss;
|
|
}
|
|
return .{
|
|
.total_value = total_value,
|
|
.total_cost = total_cost,
|
|
.unrealized_gain_loss = unrealized_gain_loss,
|
|
.unrealized_return = if (total_cost > 0) unrealized_gain_loss / total_cost else 0,
|
|
.realized_gain_loss = 0,
|
|
.allocations = allocations,
|
|
};
|
|
}
|
|
|
|
test "display shows header and summary" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
|
|
var lots = [_]zfin.Lot{
|
|
.{ .symbol = "AAPL", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 15), .open_price = 150.0 },
|
|
.{ .symbol = "GOOG", .shares = 5, .open_date = zfin.Date.fromYmd(2023, 6, 1), .open_price = 120.0 },
|
|
};
|
|
var portfolio = testPortfolio(&lots);
|
|
|
|
var positions = [_]zfin.Position{
|
|
.{ .symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
|
|
.{ .symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .total_cost = 600.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
|
|
};
|
|
|
|
var allocs = [_]zfin.risk.Allocation{
|
|
.{ .symbol = "AAPL", .display_symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .current_price = 175.0, .market_value = 1750.0, .cost_basis = 1500.0, .weight = 0.745, .unrealized_gain_loss = 250.0, .unrealized_return = 0.167 },
|
|
.{ .symbol = "GOOG", .display_symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .current_price = 140.0, .market_value = 700.0, .cost_basis = 600.0, .weight = 0.255, .unrealized_gain_loss = 100.0, .unrealized_return = 0.167 },
|
|
};
|
|
var summary = testSummary(&allocs);
|
|
|
|
var prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("AAPL", 175.0);
|
|
try prices.put("GOOG", 140.0);
|
|
|
|
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
|
defer candle_map.deinit();
|
|
|
|
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer watch_prices.deinit();
|
|
|
|
const watch_syms: []const []const u8 = &.{};
|
|
|
|
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
|
const out = w.buffered();
|
|
|
|
// Header present
|
|
try testing.expect(std.mem.indexOf(u8, out, "Portfolio Summary (test.srf)") != null);
|
|
// Symbols present
|
|
try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "GOOG") != null);
|
|
// Column headers present
|
|
try testing.expect(std.mem.indexOf(u8, out, "Symbol") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "Market Value") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "Gain/Loss") != null);
|
|
// TOTAL line present
|
|
try testing.expect(std.mem.indexOf(u8, out, "TOTAL") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "100.0%") != null);
|
|
// No ANSI codes
|
|
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
|
}
|
|
|
|
test "display with watchlist" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
|
|
var lots = [_]zfin.Lot{
|
|
.{ .symbol = "VTI", .shares = 20, .open_date = zfin.Date.fromYmd(2022, 3, 1), .open_price = 200.0 },
|
|
};
|
|
var portfolio = testPortfolio(&lots);
|
|
|
|
var positions = [_]zfin.Position{
|
|
.{ .symbol = "VTI", .shares = 20, .avg_cost = 200.0, .total_cost = 4000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
|
|
};
|
|
|
|
var allocs = [_]zfin.risk.Allocation{
|
|
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 20, .avg_cost = 200.0, .current_price = 220.0, .market_value = 4400.0, .cost_basis = 4000.0, .weight = 1.0, .unrealized_gain_loss = 400.0, .unrealized_return = 0.1 },
|
|
};
|
|
var summary = testSummary(&allocs);
|
|
|
|
var prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("VTI", 220.0);
|
|
|
|
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
|
defer candle_map.deinit();
|
|
|
|
// Watchlist with prices
|
|
const watch_syms: []const []const u8 = &.{ "TSLA", "NVDA" };
|
|
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer watch_prices.deinit();
|
|
try watch_prices.put("TSLA", 250.50);
|
|
try watch_prices.put("NVDA", 800.25);
|
|
|
|
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
|
const out = w.buffered();
|
|
|
|
// Watchlist header and symbols
|
|
try testing.expect(std.mem.indexOf(u8, out, "Watchlist:") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "TSLA") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "NVDA") != null);
|
|
}
|
|
|
|
test "display with options section" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
|
|
var lots = [_]zfin.Lot{
|
|
.{ .symbol = "SPY", .shares = 50, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 400.0 },
|
|
.{ .symbol = "SPY 240119C00450000", .shares = 2, .open_date = zfin.Date.fromYmd(2023, 6, 1), .open_price = 5.50, .security_type = .option },
|
|
};
|
|
var portfolio = testPortfolio(&lots);
|
|
|
|
var positions = [_]zfin.Position{
|
|
.{ .symbol = "SPY", .shares = 50, .avg_cost = 400.0, .total_cost = 20000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
|
|
};
|
|
|
|
var allocs = [_]zfin.risk.Allocation{
|
|
.{ .symbol = "SPY", .display_symbol = "SPY", .shares = 50, .avg_cost = 400.0, .current_price = 450.0, .market_value = 22500.0, .cost_basis = 20000.0, .weight = 1.0, .unrealized_gain_loss = 2500.0, .unrealized_return = 0.125 },
|
|
};
|
|
var summary = testSummary(&allocs);
|
|
// Include option cost in totals (like run() does)
|
|
summary.total_value += portfolio.totalOptionCost();
|
|
summary.total_cost += portfolio.totalOptionCost();
|
|
|
|
var prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("SPY", 450.0);
|
|
|
|
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
|
defer candle_map.deinit();
|
|
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer watch_prices.deinit();
|
|
const watch_syms: []const []const u8 = &.{};
|
|
|
|
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
|
const out = w.buffered();
|
|
|
|
// Options section present
|
|
try testing.expect(std.mem.indexOf(u8, out, "Options") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "SPY 240119C00450000") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "Cost/Ctrct") != null);
|
|
}
|
|
|
|
test "display with CDs and cash" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
|
|
var lots = [_]zfin.Lot{
|
|
.{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 },
|
|
.{ .symbol = "912828ZT0", .shares = 10000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 100.0, .security_type = .cd, .rate = 4.5, .maturity_date = zfin.Date.fromYmd(2025, 6, 15) },
|
|
.{ .symbol = "CASH", .shares = 5000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 0, .security_type = .cash, .account = "Brokerage" },
|
|
};
|
|
var portfolio = testPortfolio(&lots);
|
|
|
|
var positions = [_]zfin.Position{
|
|
.{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
|
|
};
|
|
|
|
var allocs = [_]zfin.risk.Allocation{
|
|
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 },
|
|
};
|
|
var summary = testSummary(&allocs);
|
|
summary.total_value += portfolio.totalCash() + portfolio.totalCdFaceValue();
|
|
summary.total_cost += portfolio.totalCash() + portfolio.totalCdFaceValue();
|
|
|
|
var prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("VTI", 220.0);
|
|
|
|
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
|
defer candle_map.deinit();
|
|
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer watch_prices.deinit();
|
|
const watch_syms: []const []const u8 = &.{};
|
|
|
|
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
|
const out = w.buffered();
|
|
|
|
// CDs section present
|
|
try testing.expect(std.mem.indexOf(u8, out, "Certificates of Deposit") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "912828ZT0") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "4.50%") != null);
|
|
|
|
// Cash section present
|
|
try testing.expect(std.mem.indexOf(u8, out, "Cash") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "Brokerage") != null);
|
|
}
|
|
|
|
test "display realized PnL shown when nonzero" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
|
|
var lots = [_]zfin.Lot{
|
|
.{ .symbol = "MSFT", .shares = 10, .open_date = zfin.Date.fromYmd(2022, 1, 1), .open_price = 300.0 },
|
|
.{ .symbol = "MSFT", .shares = 5, .open_date = zfin.Date.fromYmd(2022, 6, 1), .open_price = 280.0, .close_date = zfin.Date.fromYmd(2023, 6, 1), .close_price = 350.0 },
|
|
};
|
|
var portfolio = testPortfolio(&lots);
|
|
|
|
var positions = [_]zfin.Position{
|
|
.{ .symbol = "MSFT", .shares = 10, .avg_cost = 300.0, .total_cost = 3000.0, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = 350.0 },
|
|
};
|
|
|
|
var allocs = [_]zfin.risk.Allocation{
|
|
.{ .symbol = "MSFT", .display_symbol = "MSFT", .shares = 10, .avg_cost = 300.0, .current_price = 400.0, .market_value = 4000.0, .cost_basis = 3000.0, .weight = 1.0, .unrealized_gain_loss = 1000.0, .unrealized_return = 0.333 },
|
|
};
|
|
var summary = testSummary(&allocs);
|
|
summary.realized_gain_loss = 350.0;
|
|
|
|
var prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("MSFT", 400.0);
|
|
|
|
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
|
defer candle_map.deinit();
|
|
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer watch_prices.deinit();
|
|
const watch_syms: []const []const u8 = &.{};
|
|
|
|
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
|
const out = w.buffered();
|
|
|
|
try testing.expect(std.mem.indexOf(u8, out, "Realized P&L") != null);
|
|
}
|
|
|
|
test "display empty watchlist not shown" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
|
|
var lots = [_]zfin.Lot{
|
|
.{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 },
|
|
};
|
|
var portfolio = testPortfolio(&lots);
|
|
|
|
var positions = [_]zfin.Position{
|
|
.{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
|
|
};
|
|
|
|
var allocs = [_]zfin.risk.Allocation{
|
|
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 },
|
|
};
|
|
var summary = testSummary(&allocs);
|
|
|
|
var prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("VTI", 220.0);
|
|
|
|
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
|
defer candle_map.deinit();
|
|
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer watch_prices.deinit();
|
|
const watch_syms: []const []const u8 = &.{};
|
|
|
|
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
|
const out = w.buffered();
|
|
|
|
// Watchlist header should NOT appear when there are no watch symbols
|
|
try testing.expect(std.mem.indexOf(u8, out, "Watchlist") == null);
|
|
}
|