update pnl -> gain_loss

This commit is contained in:
Emil Lerch 2026-03-03 13:32:56 -08:00
parent 25a7d06d40
commit 9819c93cfe
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 72 additions and 69 deletions

View file

@ -84,11 +84,11 @@ pub const PortfolioSummary = struct {
/// Total cost basis of open positions /// Total cost basis of open positions
total_cost: f64, total_cost: f64,
/// Total unrealized P&L /// Total unrealized P&L
unrealized_pnl: f64, unrealized_gain_loss: f64,
/// Total unrealized return (decimal) /// Total unrealized return (decimal)
unrealized_return: f64, unrealized_return: f64,
/// Total realized P&L from closed lots /// Total realized P&L from closed lots
realized_pnl: f64, realized_gain_loss: f64,
/// Per-symbol breakdown /// Per-symbol breakdown
allocations: []Allocation, allocations: []Allocation,
@ -99,7 +99,7 @@ pub const PortfolioSummary = struct {
/// Adjust the summary to include non-stock assets (cash, CDs, options) in the totals. /// Adjust the summary to include non-stock assets (cash, CDs, options) in the totals.
/// Cash and CDs add equally to value and cost (no gain/loss). /// Cash and CDs add equally to value and cost (no gain/loss).
/// Options add at cost basis (no live pricing). /// Options add at cost basis (no live pricing).
/// This keeps unrealized_pnl correct (only stocks contribute market gains) /// This keeps unrealized_gain_loss correct (only stocks contribute market gains)
/// but dilutes the return% against the full portfolio cost base. /// but dilutes the return% against the full portfolio cost base.
pub fn adjustForNonStockAssets(self: *PortfolioSummary, portfolio: @import("../models/portfolio.zig").Portfolio) void { pub fn adjustForNonStockAssets(self: *PortfolioSummary, portfolio: @import("../models/portfolio.zig").Portfolio) void {
const cash_total = portfolio.totalCash(); const cash_total = portfolio.totalCash();
@ -109,7 +109,7 @@ pub const PortfolioSummary = struct {
self.total_value += non_stock; self.total_value += non_stock;
self.total_cost += non_stock; self.total_cost += non_stock;
if (self.total_cost > 0) { if (self.total_cost > 0) {
self.unrealized_return = self.unrealized_pnl / self.total_cost; self.unrealized_return = self.unrealized_gain_loss / self.total_cost;
} }
// Reweight allocations against grand total // Reweight allocations against grand total
if (self.total_value > 0) { if (self.total_value > 0) {
@ -131,7 +131,7 @@ pub const Allocation = struct {
market_value: f64, market_value: f64,
cost_basis: f64, cost_basis: f64,
weight: f64, // fraction of total portfolio weight: f64, // fraction of total portfolio
unrealized_pnl: f64, unrealized_gain_loss: f64,
unrealized_return: f64, unrealized_return: f64,
/// True if current_price came from a manual override rather than live API data. /// True if current_price came from a manual override rather than live API data.
is_manual_price: bool = false, is_manual_price: bool = false,
@ -161,7 +161,7 @@ pub fn portfolioSummary(
const mv = pos.shares * price; const mv = pos.shares * price;
total_value += mv; total_value += mv;
total_cost += pos.total_cost; total_cost += pos.total_cost;
total_realized += pos.realized_pnl; total_realized += pos.realized_gain_loss;
// For CUSIPs with a note, derive a short display label from the note. // For CUSIPs with a note, derive a short display label from the note.
const display = if (fmt.isCusipLike(pos.symbol) and pos.note != null) const display = if (fmt.isCusipLike(pos.symbol) and pos.note != null)
@ -178,7 +178,7 @@ pub fn portfolioSummary(
.market_value = mv, .market_value = mv,
.cost_basis = pos.total_cost, .cost_basis = pos.total_cost,
.weight = 0, // filled below .weight = 0, // filled below
.unrealized_pnl = mv - pos.total_cost, .unrealized_gain_loss = mv - pos.total_cost,
.unrealized_return = if (pos.total_cost > 0) (mv / pos.total_cost) - 1.0 else 0, .unrealized_return = if (pos.total_cost > 0) (mv / pos.total_cost) - 1.0 else 0,
.is_manual_price = if (manual_prices) |mp| mp.contains(pos.symbol) else false, .is_manual_price = if (manual_prices) |mp| mp.contains(pos.symbol) else false,
.account = pos.account, .account = pos.account,
@ -195,9 +195,9 @@ pub fn portfolioSummary(
return .{ return .{
.total_value = total_value, .total_value = total_value,
.total_cost = total_cost, .total_cost = total_cost,
.unrealized_pnl = total_value - total_cost, .unrealized_gain_loss = total_value - total_cost,
.unrealized_return = if (total_cost > 0) (total_value / total_cost) - 1.0 else 0, .unrealized_return = if (total_cost > 0) (total_value / total_cost) - 1.0 else 0,
.realized_pnl = total_realized, .realized_gain_loss = total_realized,
.allocations = try allocs.toOwnedSlice(allocator), .allocations = try allocs.toOwnedSlice(allocator),
}; };
} }
@ -564,22 +564,22 @@ test "adjustForNonStockAssets" {
}; };
const pf = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; const pf = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
var allocs = [_]Allocation{ var allocs = [_]Allocation{
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200, .current_price = 220, .market_value = 2200, .cost_basis = 2000, .weight = 1.0, .unrealized_pnl = 200, .unrealized_return = 0.1 }, .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200, .current_price = 220, .market_value = 2200, .cost_basis = 2000, .weight = 1.0, .unrealized_gain_loss = 200, .unrealized_return = 0.1 },
}; };
var summary = PortfolioSummary{ var summary = PortfolioSummary{
.total_value = 2200, .total_value = 2200,
.total_cost = 2000, .total_cost = 2000,
.unrealized_pnl = 200, .unrealized_gain_loss = 200,
.unrealized_return = 0.1, .unrealized_return = 0.1,
.realized_pnl = 0, .realized_gain_loss = 0,
.allocations = &allocs, .allocations = &allocs,
}; };
summary.adjustForNonStockAssets(pf); summary.adjustForNonStockAssets(pf);
// non_stock = 5000 + 10000 + (2*5) = 15010 // non_stock = 5000 + 10000 + (2*5) = 15010
try std.testing.expectApproxEqAbs(@as(f64, 17210), summary.total_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 17210), summary.total_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 17010), summary.total_cost, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 17010), summary.total_cost, 0.01);
// unrealized_pnl unchanged (200), unrealized_return = 200 / 17010 // unrealized_gain_loss unchanged (200), unrealized_return = 200 / 17010
try std.testing.expectApproxEqAbs(@as(f64, 200), summary.unrealized_pnl, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 200), summary.unrealized_gain_loss, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 200.0 / 17010.0), summary.unrealized_return, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 200.0 / 17010.0), summary.unrealized_return, 0.001);
// Weight recomputed against new total // Weight recomputed against new total
try std.testing.expectApproxEqAbs(@as(f64, 2200.0 / 17210.0), allocs[0].weight, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 2200.0 / 17210.0), allocs[0].weight, 0.001);
@ -594,8 +594,8 @@ test "buildFallbackPrices" {
.{ .symbol = "CUSIP1", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .price = 105.5 }, .{ .symbol = "CUSIP1", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .price = 105.5 },
}; };
var positions = [_]Position{ var positions = [_]Position{
.{ .symbol = "AAPL", .shares = 10, .avg_cost = 150, .total_cost = 1500, .open_lots = 1, .closed_lots = 0, .realized_pnl = 0 }, .{ .symbol = "AAPL", .shares = 10, .avg_cost = 150, .total_cost = 1500, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
.{ .symbol = "CUSIP1", .shares = 5, .avg_cost = 100, .total_cost = 500, .open_lots = 1, .closed_lots = 0, .realized_pnl = 0 }, .{ .symbol = "CUSIP1", .shares = 5, .avg_cost = 100, .total_cost = 500, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
}; };
var prices = std.StringHashMap(f64).init(alloc); var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit(); defer prices.deinit();

View file

@ -233,10 +233,10 @@ pub fn display(
var val_buf: [24]u8 = undefined; var val_buf: [24]u8 = undefined;
var cost_buf: [24]u8 = undefined; var cost_buf: [24]u8 = undefined;
var gl_buf: [24]u8 = undefined; var gl_buf: [24]u8 = undefined;
const gl_abs = if (summary.unrealized_pnl >= 0) summary.unrealized_pnl else -summary.unrealized_pnl; 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.fmtMoney(&val_buf, summary.total_value), fmt.fmtMoney(&cost_buf, summary.total_cost) }); try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoney(&val_buf, summary.total_value), fmt.fmtMoney(&cost_buf, summary.total_cost) });
try cli.setGainLoss(out, color, summary.unrealized_pnl); try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
if (summary.unrealized_pnl >= 0) { if (summary.unrealized_gain_loss >= 0) {
try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 }); try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
} else { } else {
try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 }); try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
@ -310,9 +310,9 @@ pub fn display(
var cost_buf2: [24]u8 = undefined; var cost_buf2: [24]u8 = undefined;
var price_buf2: [24]u8 = undefined; var price_buf2: [24]u8 = undefined;
var gl_val_buf: [24]u8 = undefined; var gl_val_buf: [24]u8 = undefined;
const gl_abs = if (a.unrealized_pnl >= 0) a.unrealized_pnl else -a.unrealized_pnl; const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss;
const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs); const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs);
const sign: []const u8 = if (a.unrealized_pnl >= 0) "+" else "-"; const sign: []const u8 = if (a.unrealized_gain_loss >= 0) "+" else "-";
// Date + ST/LT for single-lot positions // Date + ST/LT for single-lot positions
var date_col: [24]u8 = .{' '} ** 24; var date_col: [24]u8 = .{' '} ** 24;
@ -332,7 +332,7 @@ pub fn display(
}); });
try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)}); try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)});
try out.print(" {s:>16} ", .{fmt.fmtMoney(&mv_buf, a.market_value)}); try out.print(" {s:>16} ", .{fmt.fmtMoney(&mv_buf, a.market_value)});
try cli.setGainLoss(out, color, a.unrealized_pnl); try cli.setGainLoss(out, color, a.unrealized_gain_loss);
try out.print("{s}{s:>13}", .{ sign, gl_money }); try out.print("{s}{s:>13}", .{ sign, gl_money });
if (a.is_manual_price) { if (a.is_manual_price) {
try cli.setFg(out, color, cli.CLR_WARNING); try cli.setFg(out, color, cli.CLR_WARNING);
@ -421,12 +421,12 @@ pub fn display(
{ {
var total_mv_buf: [24]u8 = undefined; var total_mv_buf: [24]u8 = undefined;
var total_gl_buf: [24]u8 = undefined; var total_gl_buf: [24]u8 = undefined;
const gl_abs = if (summary.unrealized_pnl >= 0) summary.unrealized_pnl else -summary.unrealized_pnl; 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} ", .{ try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {s:>16} ", .{
"", "", "", "TOTAL", fmt.fmtMoney(&total_mv_buf, summary.total_value), "", "", "", "TOTAL", fmt.fmtMoney(&total_mv_buf, summary.total_value),
}); });
try cli.setGainLoss(out, color, summary.unrealized_pnl); try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
if (summary.unrealized_pnl >= 0) { if (summary.unrealized_gain_loss >= 0) {
try out.print("+{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)}); try out.print("+{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)});
} else { } else {
try out.print("-{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)}); try out.print("-{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)});
@ -435,11 +435,11 @@ pub fn display(
try out.print(" {s:>7}\n", .{"100.0%"}); try out.print(" {s:>7}\n", .{"100.0%"});
} }
if (summary.realized_pnl != 0) { if (summary.realized_gain_loss != 0) {
var rpl_buf: [24]u8 = undefined; var rpl_buf: [24]u8 = undefined;
const rpl_abs = if (summary.realized_pnl >= 0) summary.realized_pnl else -summary.realized_pnl; 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_pnl); try cli.setGainLoss(out, color, summary.realized_gain_loss);
if (summary.realized_pnl >= 0) { if (summary.realized_gain_loss >= 0) {
try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)}); try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)});
} else { } else {
try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)}); try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)});
@ -729,18 +729,18 @@ fn testPortfolio(lots: []const zfin.Lot) zfin.Portfolio {
fn testSummary(allocations: []zfin.risk.Allocation) zfin.risk.PortfolioSummary { fn testSummary(allocations: []zfin.risk.Allocation) zfin.risk.PortfolioSummary {
var total_value: f64 = 0; var total_value: f64 = 0;
var total_cost: f64 = 0; var total_cost: f64 = 0;
var unrealized_pnl: f64 = 0; var unrealized_gain_loss: f64 = 0;
for (allocations) |a| { for (allocations) |a| {
total_value += a.market_value; total_value += a.market_value;
total_cost += a.cost_basis; total_cost += a.cost_basis;
unrealized_pnl += a.unrealized_pnl; unrealized_gain_loss += a.unrealized_gain_loss;
} }
return .{ return .{
.total_value = total_value, .total_value = total_value,
.total_cost = total_cost, .total_cost = total_cost,
.unrealized_pnl = unrealized_pnl, .unrealized_gain_loss = unrealized_gain_loss,
.unrealized_return = if (total_cost > 0) unrealized_pnl / total_cost else 0, .unrealized_return = if (total_cost > 0) unrealized_gain_loss / total_cost else 0,
.realized_pnl = 0, .realized_gain_loss = 0,
.allocations = allocations, .allocations = allocations,
}; };
} }
@ -756,13 +756,13 @@ test "display shows header and summary" {
var portfolio = testPortfolio(&lots); var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{ var positions = [_]zfin.Position{
.{ .symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_pnl = 0 }, .{ .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_pnl = 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{ 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_pnl = 250.0, .unrealized_return = 0.167 }, .{ .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_pnl = 100.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 summary = testSummary(&allocs);
@ -808,11 +808,11 @@ test "display with watchlist" {
var portfolio = testPortfolio(&lots); var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{ var positions = [_]zfin.Position{
.{ .symbol = "VTI", .shares = 20, .avg_cost = 200.0, .total_cost = 4000.0, .open_lots = 1, .closed_lots = 0, .realized_pnl = 0 }, .{ .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{ 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_pnl = 400.0, .unrealized_return = 0.1 }, .{ .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 summary = testSummary(&allocs);
@ -850,11 +850,11 @@ test "display with options section" {
var portfolio = testPortfolio(&lots); var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{ var positions = [_]zfin.Position{
.{ .symbol = "SPY", .shares = 50, .avg_cost = 400.0, .total_cost = 20000.0, .open_lots = 1, .closed_lots = 0, .realized_pnl = 0 }, .{ .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{ 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_pnl = 2500.0, .unrealized_return = 0.125 }, .{ .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); var summary = testSummary(&allocs);
// Include option cost in totals (like run() does) // Include option cost in totals (like run() does)
@ -892,11 +892,11 @@ test "display with CDs and cash" {
var portfolio = testPortfolio(&lots); var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{ var positions = [_]zfin.Position{
.{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_pnl = 0 }, .{ .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{ 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_pnl = 200.0, .unrealized_return = 0.1 }, .{ .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 summary = testSummary(&allocs);
summary.total_value += portfolio.totalCash() + portfolio.totalCdFaceValue(); summary.total_value += portfolio.totalCash() + portfolio.totalCdFaceValue();
@ -936,14 +936,14 @@ test "display realized PnL shown when nonzero" {
var portfolio = testPortfolio(&lots); var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{ var positions = [_]zfin.Position{
.{ .symbol = "MSFT", .shares = 10, .avg_cost = 300.0, .total_cost = 3000.0, .open_lots = 1, .closed_lots = 1, .realized_pnl = 350.0 }, .{ .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{ 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_pnl = 1000.0, .unrealized_return = 0.333 }, .{ .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); var summary = testSummary(&allocs);
summary.realized_pnl = 350.0; summary.realized_gain_loss = 350.0;
var prices = std.StringHashMap(f64).init(testing.allocator); var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit(); defer prices.deinit();
@ -971,11 +971,11 @@ test "display empty watchlist not shown" {
var portfolio = testPortfolio(&lots); var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{ var positions = [_]zfin.Position{
.{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_pnl = 0 }, .{ .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{ 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_pnl = 200.0, .unrealized_return = 0.1 }, .{ .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 summary = testSummary(&allocs);

View file

@ -80,12 +80,15 @@ pub const Lot = struct {
return self.shares * current_price; return self.shares * current_price;
} }
pub fn realizedPnl(self: Lot) ?f64 { /// Realized gain/loss for a closed lot: shares * (close_price - open_price).
/// Returns null if the lot is still open.
pub fn realizedGainLoss(self: Lot) ?f64 {
const cp = self.close_price orelse return null; const cp = self.close_price orelse return null;
return self.shares * (cp - self.open_price); return self.shares * (cp - self.open_price);
} }
pub fn unrealizedPnl(self: Lot, current_price: f64) f64 { /// Unrealized gain/loss for an open lot at the given market price.
pub fn unrealizedGainLoss(self: Lot, current_price: f64) f64 {
return self.shares * (current_price - self.open_price); return self.shares * (current_price - self.open_price);
} }
@ -110,7 +113,7 @@ pub const Position = struct {
/// Number of closed lots /// Number of closed lots
closed_lots: u32, closed_lots: u32,
/// Total realized P&L from closed lots /// Total realized P&L from closed lots
realized_pnl: f64, realized_gain_loss: f64,
/// Account name (shared across lots, or "Multiple" if mixed). /// Account name (shared across lots, or "Multiple" if mixed).
account: []const u8 = "", account: []const u8 = "",
/// Note from the first lot (e.g. "VANGUARD TARGET 2035"). /// Note from the first lot (e.g. "VANGUARD TARGET 2035").
@ -222,7 +225,7 @@ pub const Portfolio = struct {
.total_cost = 0, .total_cost = 0,
.open_lots = 0, .open_lots = 0,
.closed_lots = 0, .closed_lots = 0,
.realized_pnl = 0, .realized_gain_loss = 0,
.account = lot.account orelse "", .account = lot.account orelse "",
.note = lot.note, .note = lot.note,
}; };
@ -240,7 +243,7 @@ pub const Portfolio = struct {
entry.value_ptr.open_lots += 1; entry.value_ptr.open_lots += 1;
} else { } else {
entry.value_ptr.closed_lots += 1; entry.value_ptr.closed_lots += 1;
entry.value_ptr.realized_pnl += lot.realizedPnl() orelse 0; entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0;
} }
} }
@ -272,11 +275,11 @@ pub const Portfolio = struct {
} }
/// Total realized P&L from all closed stock lots. /// Total realized P&L from all closed stock lots.
pub fn totalRealizedPnl(self: Portfolio) f64 { pub fn totalRealizedGainLoss(self: Portfolio) f64 {
var total: f64 = 0; var total: f64 = 0;
for (self.lots) |lot| { for (self.lots) |lot| {
if (lot.lot_type == .stock) { if (lot.lot_type == .stock) {
if (lot.realizedPnl()) |pnl| total += pnl; if (lot.realizedGainLoss()) |pnl| total += pnl;
} }
} }
return total; return total;
@ -353,8 +356,8 @@ test "lot basics" {
try std.testing.expect(lot.isOpen()); try std.testing.expect(lot.isOpen());
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedPnl(200.0), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedGainLoss(200.0), 0.01);
try std.testing.expect(lot.realizedPnl() == null); try std.testing.expect(lot.realizedGainLoss() == null);
} }
test "closed lot" { test "closed lot" {
@ -367,7 +370,7 @@ test "closed lot" {
.close_price = 200.0, .close_price = 200.0,
}; };
try std.testing.expect(!lot.isOpen()); try std.testing.expect(!lot.isOpen());
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedPnl().?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLoss().?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001);
} }
@ -398,7 +401,7 @@ test "portfolio positions" {
try std.testing.expectApproxEqAbs(@as(f64, 15.0), aapl.?.shares, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 15.0), aapl.?.shares, 0.01);
try std.testing.expectEqual(@as(u32, 2), aapl.?.open_lots); try std.testing.expectEqual(@as(u32, 2), aapl.?.open_lots);
try std.testing.expectEqual(@as(u32, 1), aapl.?.closed_lots); try std.testing.expectEqual(@as(u32, 1), aapl.?.closed_lots);
try std.testing.expectApproxEqAbs(@as(f64, 75.0), aapl.?.realized_pnl, 0.01); // 3 * (155-130) try std.testing.expectApproxEqAbs(@as(f64, 75.0), aapl.?.realized_gain_loss, 0.01); // 3 * (155-130)
} }
test "LotType label and fromString" { test "LotType label and fromString" {
@ -451,8 +454,8 @@ test "Portfolio totals" {
// totalCostBasis: only open stock lots -> 10 * 150 = 1500 // totalCostBasis: only open stock lots -> 10 * 150 = 1500
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), portfolio.totalCostBasis(), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 1500.0), portfolio.totalCostBasis(), 0.01);
// totalRealizedPnl: closed stock lots -> 5 * (160-140) = 100 // totalRealizedGainLoss: closed stock lots -> 5 * (160-140) = 100
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedPnl(), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(), 0.01);
// totalCash // totalCash
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(), 0.01);
// totalIlliquid // totalIlliquid

View file

@ -1244,7 +1244,7 @@ const App = struct {
.avg_cost => lhs.avg_cost < rhs.avg_cost, .avg_cost => lhs.avg_cost < rhs.avg_cost,
.price => lhs.current_price < rhs.current_price, .price => lhs.current_price < rhs.current_price,
.market_value => lhs.market_value < rhs.market_value, .market_value => lhs.market_value < rhs.market_value,
.gain_loss => lhs.unrealized_pnl < rhs.unrealized_pnl, .gain_loss => lhs.unrealized_gain_loss < rhs.unrealized_gain_loss,
.weight => lhs.weight < rhs.weight, .weight => lhs.weight < rhs.weight,
.account => std.mem.lessThan(u8, lhs.account, rhs.account), .account => std.mem.lessThan(u8, lhs.account, rhs.account),
}; };
@ -2036,12 +2036,12 @@ const App = struct {
var gl_buf: [24]u8 = undefined; var gl_buf: [24]u8 = undefined;
const val_str = fmt.fmtMoney(&val_buf, s.total_value); const val_str = fmt.fmtMoney(&val_buf, s.total_value);
const cost_str = fmt.fmtMoney(&cost_buf, s.total_cost); const cost_str = fmt.fmtMoney(&cost_buf, s.total_cost);
const gl_abs = if (s.unrealized_pnl >= 0) s.unrealized_pnl else -s.unrealized_pnl; const gl_abs = if (s.unrealized_gain_loss >= 0) s.unrealized_gain_loss else -s.unrealized_gain_loss;
const gl_str = fmt.fmtMoney(&gl_buf, gl_abs); const gl_str = fmt.fmtMoney(&gl_buf, gl_abs);
const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{ const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{
val_str, cost_str, if (s.unrealized_pnl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, s.unrealized_return * 100.0, val_str, cost_str, if (s.unrealized_gain_loss >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, s.unrealized_return * 100.0,
}); });
const summary_style = if (s.unrealized_pnl >= 0) th.positiveStyle() else th.negativeStyle(); const summary_style = if (s.unrealized_gain_loss >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = summary_text, .style = summary_style }); try lines.append(arena, .{ .text = summary_text, .style = summary_style });
// "as of" date indicator // "as of" date indicator
@ -2137,12 +2137,12 @@ const App = struct {
const is_expanded = is_multi and row.pos_idx < self.expanded.len and self.expanded[row.pos_idx]; const is_expanded = is_multi and row.pos_idx < self.expanded.len and self.expanded[row.pos_idx];
const arrow: []const u8 = if (!is_multi) " " else if (is_expanded) "v " else "> "; const arrow: []const u8 = if (!is_multi) " " else if (is_expanded) "v " else "> ";
const star: []const u8 = if (is_active_sym) "* " else " "; const star: []const u8 = if (is_active_sym) "* " else " ";
const pnl_pct = if (a.cost_basis > 0) (a.unrealized_pnl / a.cost_basis) * 100.0 else @as(f64, 0); const pnl_pct = if (a.cost_basis > 0) (a.unrealized_gain_loss / a.cost_basis) * 100.0 else @as(f64, 0);
var gl_val_buf: [24]u8 = undefined; var gl_val_buf: [24]u8 = undefined;
const gl_abs = if (a.unrealized_pnl >= 0) a.unrealized_pnl else -a.unrealized_pnl; const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss;
const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs); const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs);
var pnl_buf: [20]u8 = undefined; var pnl_buf: [20]u8 = undefined;
const pnl_str = if (a.unrealized_pnl >= 0) const pnl_str = if (a.unrealized_gain_loss >= 0)
std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?" std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?"
else else
std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?"; std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?";