From facc2339761a880bc01088e39b27649908569213 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 1 Mar 2026 11:57:29 -0800 Subject: [PATCH] ai: deduplicate cli/tli display functions --- src/analytics/risk.zig | 114 +++++++++++ src/commands/analysis.zig | 63 ++----- src/commands/earnings.zig | 28 +-- src/commands/perf.zig | 34 ++-- src/commands/portfolio.zig | 117 +++--------- src/commands/quote.zig | 14 +- src/format.zig | 378 ++++++++++++++++++++++++++++++++++++- src/tui.zig | 306 +++++++----------------------- 8 files changed, 615 insertions(+), 439 deletions(-) diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index 800ee8d..fc9a199 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -95,6 +95,29 @@ pub const PortfolioSummary = struct { pub fn deinit(self: *PortfolioSummary, allocator: std.mem.Allocator) void { allocator.free(self.allocations); } + + /// 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). + /// 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. + pub fn adjustForNonStockAssets(self: *PortfolioSummary, portfolio: @import("../models/portfolio.zig").Portfolio) void { + const cash_total = portfolio.totalCash(); + const cd_total = portfolio.totalCdFaceValue(); + const opt_total = portfolio.totalOptionCost(); + const non_stock = cash_total + cd_total + opt_total; + self.total_value += non_stock; + self.total_cost += non_stock; + if (self.total_cost > 0) { + self.unrealized_return = self.unrealized_pnl / self.total_cost; + } + // Reweight allocations against grand total + if (self.total_value > 0) { + for (self.allocations) |*a| { + a.weight = a.market_value / self.total_value; + } + } + } }; pub const Allocation = struct { @@ -179,6 +202,39 @@ pub fn portfolioSummary( }; } +/// Build fallback prices for symbols that failed API fetch. +/// 1. Use manual `price::` from SRF if available +/// 2. Otherwise use position avg_cost so the position still appears +/// Populates `prices` and returns a set of symbols whose price is manual/fallback. +pub fn buildFallbackPrices( + allocator: std.mem.Allocator, + lots: []const @import("../models/portfolio.zig").Lot, + positions: []const @import("../models/portfolio.zig").Position, + prices: *std.StringHashMap(f64), +) !std.StringHashMap(void) { + var manual_price_set = std.StringHashMap(void).init(allocator); + errdefer manual_price_set.deinit(); + // First pass: manual price:: overrides + for (lots) |lot| { + if (lot.lot_type != .stock) continue; + const sym = lot.priceSymbol(); + if (lot.price) |p| { + if (!prices.contains(sym)) { + try prices.put(sym, p); + try manual_price_set.put(sym, {}); + } + } + } + // Second pass: fall back to avg_cost for anything still missing + for (positions) |pos| { + if (!prices.contains(pos.symbol) and pos.shares > 0) { + try prices.put(pos.symbol, pos.avg_cost); + try manual_price_set.put(pos.symbol, {}); + } + } + return manual_price_set; +} + // ── Historical portfolio value ─────────────────────────────── /// A lookback period for historical portfolio value. @@ -496,3 +552,61 @@ test "computeRisk insufficient data" { // Less than 21 candles -> returns null try std.testing.expect(computeRisk(&candles) == null); } + +test "adjustForNonStockAssets" { + const Portfolio = @import("../models/portfolio.zig").Portfolio; + const Lot = @import("../models/portfolio.zig").Lot; + var lots = [_]Lot{ + .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200 }, + .{ .symbol = "Cash", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .cash }, + .{ .symbol = "CD1", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .cd }, + .{ .symbol = "OPT1", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .lot_type = .option }, + }; + const pf = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; + 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 }, + }; + var summary = PortfolioSummary{ + .total_value = 2200, + .total_cost = 2000, + .unrealized_pnl = 200, + .unrealized_return = 0.1, + .realized_pnl = 0, + .allocations = &allocs, + }; + summary.adjustForNonStockAssets(pf); + // 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, 17010), summary.total_cost, 0.01); + // unrealized_pnl 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.0 / 17010.0), summary.unrealized_return, 0.001); + // Weight recomputed against new total + try std.testing.expectApproxEqAbs(@as(f64, 2200.0 / 17210.0), allocs[0].weight, 0.001); +} + +test "buildFallbackPrices" { + const Lot = @import("../models/portfolio.zig").Lot; + const Position = @import("../models/portfolio.zig").Position; + const alloc = std.testing.allocator; + var lots = [_]Lot{ + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 }, + .{ .symbol = "CUSIP1", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .price = 105.5 }, + }; + var positions = [_]Position{ + .{ .symbol = "AAPL", .shares = 10, .avg_cost = 150, .total_cost = 1500, .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_pnl = 0 }, + }; + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + // AAPL already has a live price + try prices.put("AAPL", 175.0); + // CUSIP1 has no live price -- should get manual price:: fallback + var manual = try buildFallbackPrices(alloc, &lots, &positions, &prices); + defer manual.deinit(); + // AAPL should NOT be in manual set (already had live price) + try std.testing.expect(!manual.contains("AAPL")); + // CUSIP1 should be in manual set with price 105.5 + try std.testing.expect(manual.contains("CUSIP1")); + try std.testing.expectApproxEqAbs(@as(f64, 105.5), prices.get("CUSIP1").?, 0.01); +} diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index b4a724b..3ea7f00 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -26,36 +26,20 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer // Build prices map from cache var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); - var manual_price_set = std.StringHashMap(void).init(allocator); - defer manual_price_set.deinit(); - // First pass: try cached candle prices + manual prices from lots + // First pass: try cached candle prices for (positions) |pos| { if (pos.shares <= 0) continue; if (svc.getCachedCandles(pos.symbol)) |cs| { defer allocator.free(cs); if (cs.len > 0) { try prices.put(pos.symbol, cs[cs.len - 1].close); - continue; - } - } - for (portfolio.lots) |lot| { - if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), pos.symbol)) { - if (lot.price) |mp| { - try prices.put(pos.symbol, mp); - try manual_price_set.put(pos.symbol, {}); - break; - } } } } - // Fallback to avg_cost - for (positions) |pos| { - if (!prices.contains(pos.symbol) and pos.shares > 0) { - try prices.put(pos.symbol, pos.avg_cost); - try manual_price_set.put(pos.symbol, {}); - } - } + // Build fallback prices for symbols without cached candle data + 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"); @@ -64,11 +48,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer defer summary.deinit(allocator); // Include non-stock assets in grand total (same as portfolio command) - 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.adjustForNonStockAssets(portfolio); // Load classification metadata const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0; @@ -116,8 +96,8 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer } pub fn display(result: zfin.analysis.AnalysisResult, file_path: []const u8, color: bool, out: *std.Io.Writer) !void { - const label_width: usize = 24; - const bar_width: usize = 30; + const label_width = fmt.analysis_label_width; + const bar_width = fmt.analysis_bar_width; try cli.setBold(out, color); try out.print("\nPortfolio Analysis ({s})\n", .{file_path}); @@ -189,29 +169,13 @@ pub fn display(result: zfin.analysis.AnalysisResult, file_path: []const u8, colo /// Print a breakdown section with block-element bar charts to the CLI output. pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void { - // Unicode block elements: U+2588 full, U+2589..U+258F partials (7/8..1/8) - const full_block = "\xE2\x96\x88"; - // partial_blocks[0]=7/8, [1]=3/4, ..., [6]=1/8 - const partial_blocks = [7][]const u8{ - "\xE2\x96\x89", // 7/8 - "\xE2\x96\x8A", // 3/4 - "\xE2\x96\x8B", // 5/8 - "\xE2\x96\x8C", // 1/2 - "\xE2\x96\x8D", // 3/8 - "\xE2\x96\x8E", // 1/4 - "\xE2\x96\x8F", // 1/8 - }; - for (items) |item| { var val_buf: [24]u8 = undefined; const pct = item.weight * 100.0; - // Compute filled eighths - const total_eighths: f64 = @as(f64, @floatFromInt(bar_width)) * 8.0; - const filled_eighths_f = item.weight * total_eighths; - const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths)); - const full_count = filled_eighths / 8; - const partial = filled_eighths % 8; + // Build bar using shared function + var bar_buf: [256]u8 = undefined; + const bar = fmt.buildBlockBar(&bar_buf, item.weight, bar_width); // Padded label const lbl_len = @min(item.label.len, label_width); @@ -222,12 +186,7 @@ pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.B } try out.writeAll(" "); if (color) try fmt.ansiSetFg(out, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.CLR_ACCENT[2]); - for (0..full_count) |_| try out.writeAll(full_block); - if (partial > 0) try out.writeAll(partial_blocks[8 - partial - 1]); - const used = full_count + @as(usize, if (partial > 0) 1 else 0); - if (used < bar_width) { - for (0..bar_width - used) |_| try out.writeAll(" "); - } + try out.writeAll(bar); if (color) try fmt.ansiReset(out); try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoney(&val_buf, item.value) }); } diff --git a/src/commands/earnings.zig b/src/commands/earnings.zig index 008eefa..4207bac 100644 --- a/src/commands/earnings.zig +++ b/src/commands/earnings.zig @@ -44,36 +44,18 @@ pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bo try cli.reset(out, color); for (events) |e| { - var db: [10]u8 = undefined; - const is_future = e.isFuture(); - const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true; + var row_buf: [128]u8 = undefined; + const row = fmt.fmtEarningsRow(&row_buf, e); - if (is_future) { + if (row.is_future) { try cli.setFg(out, color, cli.CLR_MUTED); - } else if (surprise_positive) { + } else if (row.is_positive) { try cli.setFg(out, color, cli.CLR_POSITIVE); } else { try cli.setFg(out, color, cli.CLR_NEGATIVE); } - try out.print("{s:>12}", .{e.date.format(&db)}); - if (e.quarter) |q| try out.print(" Q{d}", .{q}) else try out.print(" {s:>4}", .{"--"}); - if (e.estimate) |est| try out.print(" {s:>12}", .{fmtEps(est)}) else try out.print(" {s:>12}", .{"--"}); - if (e.actual) |act| try out.print(" {s:>12}", .{fmtEps(act)}) else try out.print(" {s:>12}", .{"--"}); - if (e.surpriseAmount()) |s| { - var surp_buf: [12]u8 = undefined; - const surp_str = if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?"; - try out.print(" {s:>12}", .{surp_str}); - } else { - try out.print(" {s:>12}", .{"--"}); - } - if (e.surprisePct()) |sp| { - var pct_buf: [12]u8 = undefined; - const pct_str = if (sp >= 0) std.fmt.bufPrint(&pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&pct_buf, "{d:.1}%", .{sp}) catch "?"; - try out.print(" {s:>10}", .{pct_str}); - } else { - try out.print(" {s:>10}", .{"--"}); - } + try out.print("{s}", .{row.text}); try out.print(" {s:>5}", .{@tagName(e.report_time)}); try cli.reset(out, color); try out.print("\n", .{}); diff --git a/src/commands/perf.zig b/src/commands/perf.zig index e876c94..4dfc6fe 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -108,24 +108,28 @@ pub fn printReturnsTable( for (periods, 0..) |period, i| { try out.print(" {s:<20}", .{period.label}); - if (price_arr[i]) |r| { - var rb: [32]u8 = undefined; - const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return; - try cli.setGainLoss(out, color, val); - try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)}); - try cli.reset(out, color); + var price_buf: [32]u8 = undefined; + var total_buf: [32]u8 = undefined; + const row = fmt.fmtReturnsRow( + &price_buf, + &total_buf, + price_arr[i], + if (has_total) total_arr[i] else null, + period.years > 1, + ); + + if (price_arr[i] != null) { + try cli.setGainLoss(out, color, if (row.price_positive) @as(f64, 1) else @as(f64, -1)); } else { try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" {s:>13}", .{"N/A"}); - try cli.reset(out, color); } + try out.print(" {s:>13}", .{row.price_str}); + try cli.reset(out, color); if (has_total) { - if (total_arr[i]) |r| { - var rb: [32]u8 = undefined; - const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return; - try cli.setGainLoss(out, color, val); - try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)}); + if (row.total_str) |ts| { + try cli.setGainLoss(out, color, if (row.price_positive) @as(f64, 1) else @as(f64, -1)); + try out.print(" {s:>13}", .{ts}); try cli.reset(out, color); } else { try cli.setFg(out, color, cli.CLR_MUTED); @@ -134,9 +138,9 @@ pub fn printReturnsTable( } } - if (period.years > 1) { + if (row.suffix.len > 0) { try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" ann.", .{}); + try out.print("{s}", .{row.suffix}); try cli.reset(out, color); } try out.print("\n", .{}); diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 25a3da4..0276274 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -151,29 +151,9 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer } // Compute summary - // Build fallback prices for symbols that failed API fetch: - // 1. Use manual price:: from SRF if available - // 2. Otherwise use position avg_cost (open_price) so the position still appears - var manual_price_set = std.StringHashMap(void).init(allocator); + // 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(); - // First pass: manual price:: overrides - for (portfolio.lots) |lot| { - if (lot.lot_type != .stock) continue; - const sym = lot.priceSymbol(); - if (lot.price) |p| { - if (!prices.contains(sym)) { - try prices.put(sym, p); - try manual_price_set.put(sym, {}); - } - } - } - // Second pass: fall back to avg_cost for anything still missing - for (positions) |pos| { - if (!prices.contains(pos.symbol) and pos.shares > 0) { - try prices.put(pos.symbol, pos.avg_cost); - try manual_price_set.put(pos.symbol, {}); - } - } var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch { try cli.stderrPrint("Error computing portfolio summary.\n"); @@ -189,21 +169,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer }.f); // 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; - } - } + summary.adjustForNonStockAssets(portfolio); // Build candle map once for historical snapshots and risk metrics. // This avoids parsing the full candle history multiple times. @@ -353,17 +319,10 @@ pub fn display( try cli.setFg(out, color, cli.CLR_MUTED); for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| { const snap = snapshots[pi]; - if (snap.position_count == 0) { - try out.print(" {s}: --", .{period.label()}); - } else { - const pct = snap.changePct(); - try cli.setGainLoss(out, color, pct); - if (pct >= 0) { - try out.print(" {s}: +{d:.1}%", .{ period.label(), pct }); - } else { - try out.print(" {s}: {d:.1}%", .{ period.label(), pct }); - } - } + 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); @@ -469,60 +428,33 @@ pub fn display( } // Summarize DRIP lots as ST/LT - var st_lots: usize = 0; - var st_shares: f64 = 0; - var st_cost: f64 = 0; - var st_first: ?zfin.Date = null; - var st_last: ?zfin.Date = null; - var lt_lots: usize = 0; - var lt_shares: f64 = 0; - var lt_cost: f64 = 0; - var lt_first: ?zfin.Date = null; - var lt_last: ?zfin.Date = null; + const drip = fmt.aggregateDripLots(lots_for_sym.items); - for (lots_for_sym.items) |lot| { - if (!lot.drip) continue; - const is_lt = std.mem.eql(u8, fmt.capitalGainsIndicator(lot.open_date), "LT"); - if (is_lt) { - lt_lots += 1; - lt_shares += lot.shares; - lt_cost += lot.costBasis(); - if (lt_first == null or lot.open_date.days < lt_first.?.days) lt_first = lot.open_date; - if (lt_last == null or lot.open_date.days > lt_last.?.days) lt_last = lot.open_date; - } else { - st_lots += 1; - st_shares += lot.shares; - st_cost += lot.costBasis(); - if (st_first == null or lot.open_date.days < st_first.?.days) st_first = lot.open_date; - if (st_last == null or lot.open_date.days > st_last.?.days) st_last = lot.open_date; - } - } - - if (st_lots > 0) { + 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", .{ - st_lots, - st_shares, - fmt.fmtMoney2(&avg_buf, if (st_shares > 0) st_cost / st_shares else 0), - if (st_first) |d| d.format(&d1_buf)[0..7] else "?", - if (st_last) |d| d.format(&d2_buf)[0..7] else "?", + drip.st.lot_count, + drip.st.shares, + fmt.fmtMoney2(&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 (lt_lots > 0) { + 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", .{ - lt_lots, - lt_shares, - fmt.fmtMoney2(&avg_buf2, if (lt_shares > 0) lt_cost / lt_shares else 0), - if (lt_first) |d| d.format(&d1_buf2)[0..7] else "?", - if (lt_last) |d| d.format(&d2_buf2)[0..7] else "?", + drip.lt.lot_count, + drip.lt.shares, + fmt.fmtMoney2(&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); } @@ -631,14 +563,7 @@ pub fn display( 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); + std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn); var cd_section_total: f64 = 0; for (cd_lots.items) |lot| { diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 7bbe324..14d1f50 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -96,12 +96,9 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote if (prev_close > 0) { const change = price - prev_close; const pct = (change / prev_close) * 100.0; + var chg_buf: [64]u8 = undefined; try cli.setGainLoss(out, color, change); - if (change >= 0) { - try out.print(" Change: +${d:.2} (+{d:.2}%)\n", .{ change, pct }); - } else { - try out.print(" Change: -${d:.2} ({d:.2}%)\n", .{ -change, pct }); - } + try out.print(" Change: {s}\n", .{fmt.fmtPriceChange(&chg_buf, change, pct)}); try cli.reset(out, color); } } @@ -132,13 +129,10 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote const start_idx = if (candles.len > 20) candles.len - 20 else 0; for (candles[start_idx..]) |candle| { - var db: [10]u8 = undefined; - var vb: [32]u8 = undefined; + var row_buf: [128]u8 = undefined; const day_gain = candle.close >= candle.open; try cli.setGainLoss(out, color, if (day_gain) @as(f64, 1) else @as(f64, -1)); - try out.print(" {s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{ - candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume), - }); + try out.print("{s}\n", .{fmt.fmtCandleRow(&row_buf, candle)}); try cli.reset(out, color); } try out.print("\n {d} trading days shown\n", .{candles[start_idx..].len}); diff --git a/src/format.zig b/src/format.zig index ba4d228..57ac772 100644 --- a/src/format.zig +++ b/src/format.zig @@ -6,8 +6,11 @@ const std = @import("std"); const Date = @import("models/date.zig").Date; const Candle = @import("models/candle.zig").Candle; -const OptionContract = @import("models/option.zig").OptionContract; const Lot = @import("models/portfolio.zig").Lot; +const OptionContract = @import("models/option.zig").OptionContract; + +const EarningsEvent = @import("models/earnings.zig").EarningsEvent; +const PerformanceResult = @import("analytics/performance.zig").PerformanceResult; // ── Layout constants ───────────────────────────────────────── @@ -364,8 +367,228 @@ pub fn lotSortFn(_: void, a: Lot, b: Lot) bool { return a.open_date.days > b.open_date.days; // newest first } +/// Sort lots by maturity date (earliest first). Lots without maturity sort last. +pub fn lotMaturitySortFn(_: void, a: Lot, b: Lot) bool { + 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; +} + +/// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket. +pub const DripSummary = struct { + lot_count: usize = 0, + shares: f64 = 0, + cost: f64 = 0, + first_date: ?Date = null, + last_date: ?Date = null, + + pub fn avgCost(self: DripSummary) f64 { + return if (self.shares > 0) self.cost / self.shares else 0; + } + + pub fn isEmpty(self: DripSummary) bool { + return self.lot_count == 0; + } +}; + +/// Aggregated ST and LT DRIP summaries. +pub const DripAggregation = struct { + st: DripSummary = .{}, + lt: DripSummary = .{}, +}; + +/// Aggregate DRIP lots into short-term and long-term buckets. +/// Classifies using `capitalGainsIndicator` (LT if held > 1 year). +pub fn aggregateDripLots(lots: []const Lot) DripAggregation { + var result: DripAggregation = .{}; + for (lots) |lot| { + if (!lot.drip) continue; + const is_lt = std.mem.eql(u8, capitalGainsIndicator(lot.open_date), "LT"); + const bucket: *DripSummary = if (is_lt) &result.lt else &result.st; + bucket.lot_count += 1; + bucket.shares += lot.shares; + bucket.cost += lot.costBasis(); + if (bucket.first_date == null or lot.open_date.days < bucket.first_date.?.days) + bucket.first_date = lot.open_date; + if (bucket.last_date == null or lot.open_date.days > bucket.last_date.?.days) + bucket.last_date = lot.open_date; + } + return result; +} + // ── Color helpers ──────────────────────────────────────────── +// ── Shared rendering helpers (CLI + TUI) ───────────────────── + +/// Layout constants for analysis breakdown views. +pub const analysis_label_width: usize = 24; +pub const analysis_bar_width: usize = 30; + +/// Format a signed gain/loss amount: "+$1,234.56" or "-$1,234.56". +/// Returns the formatted string and whether the value is non-negative. +pub const GainLossResult = struct { text: []const u8, positive: bool }; + +pub fn fmtGainLoss(buf: []u8, pnl: f64) GainLossResult { + const positive = pnl >= 0; + const abs_val = if (positive) pnl else -pnl; + var money_buf: [24]u8 = undefined; + const money = fmtMoney(&money_buf, abs_val); + const sign: []const u8 = if (positive) "+" else "-"; + const text = std.fmt.bufPrint(buf, "{s}{s}", .{ sign, money }) catch "?"; + return .{ .text = text, .positive = positive }; +} + +/// Format a single earnings event row (without color/style). +/// Returns the formatted text and color hint. +pub const EarningsRowResult = struct { + text: []const u8, + is_future: bool, + is_positive: bool, +}; + +pub fn fmtEarningsRow(buf: []u8, e: EarningsEvent) EarningsRowResult { + const is_future = e.isFuture(); + const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true; + + var db: [10]u8 = undefined; + const date_str = e.date.format(&db); + + var q_buf: [4]u8 = undefined; + const q_str = if (e.quarter) |q| std.fmt.bufPrint(&q_buf, "Q{d}", .{q}) catch "--" else "--"; + + var est_buf: [12]u8 = undefined; + const est_str = if (e.estimate) |est| std.fmt.bufPrint(&est_buf, "${d:.2}", .{est}) catch "--" else "--"; + + var act_buf: [12]u8 = undefined; + const act_str = if (e.actual) |act| std.fmt.bufPrint(&act_buf, "${d:.2}", .{act}) catch "--" else "--"; + + var surp_buf: [12]u8 = undefined; + const surp_str: []const u8 = if (e.surpriseAmount()) |s| + (if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?") + else + "--"; + + var surp_pct_buf: [12]u8 = undefined; + const surp_pct_str: []const u8 = if (e.surprisePct()) |sp| + (if (sp >= 0) std.fmt.bufPrint(&surp_pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&surp_pct_buf, "{d:.1}%", .{sp}) catch "?") + else + "--"; + + const text = std.fmt.bufPrint(buf, "{s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{ + date_str, q_str, est_str, act_str, surp_str, surp_pct_str, + }) catch ""; + + return .{ .text = text, .is_future = is_future, .is_positive = surprise_positive }; +} + +/// Format a single returns table row: " label +15.0% ann." +/// Returns null if the period result is null (N/A). +pub const ReturnsRowResult = struct { + price_str: []const u8, + total_str: ?[]const u8, + price_positive: bool, + suffix: []const u8, +}; + +pub fn fmtReturnsRow( + price_buf: []u8, + total_buf: []u8, + price_result: ?PerformanceResult, + total_result: ?PerformanceResult, + annualize: bool, +) ReturnsRowResult { + const performance = @import("analytics/performance.zig"); + + const ps: []const u8 = if (price_result) |r| blk: { + const val = if (annualize) r.annualized_return orelse r.total_return else r.total_return; + break :blk performance.formatReturn(price_buf, val); + } else "N/A"; + + const price_positive = if (price_result) |r| blk: { + const val = if (annualize) r.annualized_return orelse r.total_return else r.total_return; + break :blk val >= 0; + } else true; + + const ts: ?[]const u8 = if (total_result) |r| blk: { + const val = if (annualize) r.annualized_return orelse r.total_return else r.total_return; + break :blk performance.formatReturn(total_buf, val); + } else null; + + return .{ + .price_str = ps, + .total_str = ts, + .price_positive = price_positive, + .suffix = if (annualize) " ann." else "", + }; +} + +/// Build a block-element bar using Unicode eighth-blocks for sub-character precision. +/// Returns a slice from the provided buffer. +/// U+2588 █ full, U+2589..U+258F partials (7/8..1/8). +pub fn buildBlockBar(buf: []u8, weight: f64, total_chars: usize) []const u8 { + const total_eighths: f64 = @as(f64, @floatFromInt(total_chars)) * 8.0; + const filled_eighths_f = weight * total_eighths; + const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths)); + const full_blocks = filled_eighths / 8; + const partial = filled_eighths % 8; + + var pos: usize = 0; + + // Full blocks: U+2588 = E2 96 88 + for (0..full_blocks) |_| { + buf[pos] = 0xE2; + buf[pos + 1] = 0x96; + buf[pos + 2] = 0x88; + pos += 3; + } + + // Partial block + if (partial > 0) { + const code: u8 = 0x88 + @as(u8, @intCast(8 - partial)); + buf[pos] = 0xE2; + buf[pos + 1] = 0x96; + buf[pos + 2] = code; + pos += 3; + } + + // Empty spaces + const used = full_blocks + @as(usize, if (partial > 0) 1 else 0); + if (used < total_chars) { + @memset(buf[pos..][0 .. total_chars - used], ' '); + pos += total_chars - used; + } + + return buf[0..pos]; +} + +/// Format a historical snapshot as "+1.5%" or "--" for display. +pub fn fmtHistoricalChange(buf: []u8, snap_count: usize, pct: f64) []const u8 { + if (snap_count == 0) return "--"; + if (pct >= 0) { + return std.fmt.bufPrint(buf, "+{d:.1}%", .{pct}) catch "?"; + } else { + return std.fmt.bufPrint(buf, "{d:.1}%", .{pct}) catch "?"; + } +} + +/// Format a candle as a fixed-width row: " YYYY-MM-DD 150.00 155.00 149.00 153.00 50,000,000" +pub fn fmtCandleRow(buf: []u8, candle: Candle) []const u8 { + var db: [10]u8 = undefined; + var vb: [32]u8 = undefined; + return std.fmt.bufPrint(buf, " {s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}", .{ + candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmtIntCommas(&vb, candle.volume), + }) catch ""; +} + +/// Format a price change with sign: "+$3.50 (+2.04%)" or "-$3.50 (-2.04%)". +pub fn fmtPriceChange(buf: []u8, change: f64, pct: f64) []const u8 { + if (change >= 0) { + return std.fmt.bufPrint(buf, "+${d:.2} (+{d:.2}%)", .{ change, pct }) catch "?"; + } else { + return std.fmt.bufPrint(buf, "-${d:.2} ({d:.2}%)", .{ -change, pct }) catch "?"; + } +} + /// Interpolate color between two RGB values. t in [0.0, 1.0]. pub fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 { return .{ @@ -851,6 +1074,66 @@ test "lotSortFn" { try std.testing.expect(!lotSortFn({}, open_old, open_new)); } +test "lotMaturitySortFn" { + const with_maturity = Lot{ + .symbol = "CD1", + .shares = 10000, + .open_date = Date.fromYmd(2024, 1, 1), + .open_price = 100, + .lot_type = .cd, + .maturity_date = Date.fromYmd(2025, 6, 15), + }; + const later_maturity = Lot{ + .symbol = "CD2", + .shares = 10000, + .open_date = Date.fromYmd(2024, 1, 1), + .open_price = 100, + .lot_type = .cd, + .maturity_date = Date.fromYmd(2026, 1, 1), + }; + const no_maturity = Lot{ + .symbol = "CD3", + .shares = 10000, + .open_date = Date.fromYmd(2024, 1, 1), + .open_price = 100, + .lot_type = .cd, + }; + // Earlier maturity sorts first + try std.testing.expect(lotMaturitySortFn({}, with_maturity, later_maturity)); + try std.testing.expect(!lotMaturitySortFn({}, later_maturity, with_maturity)); + // No maturity sorts last + try std.testing.expect(lotMaturitySortFn({}, with_maturity, no_maturity)); + try std.testing.expect(!lotMaturitySortFn({}, no_maturity, with_maturity)); +} + +test "aggregateDripLots" { + // Two ST drip lots + one LT drip lot + one non-drip lot (should be ignored) + const lots = [_]Lot{ + .{ .symbol = "VTI", .shares = 0.5, .open_date = Date.fromYmd(2025, 6, 1), .open_price = 220, .drip = true }, + .{ .symbol = "VTI", .shares = 0.3, .open_date = Date.fromYmd(2025, 8, 1), .open_price = 230, .drip = true }, + .{ .symbol = "VTI", .shares = 0.2, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 200, .drip = true }, + .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 210, .drip = false }, + }; + const agg = aggregateDripLots(&lots); + // The 2023 lot is >1 year old -> LT, the 2025 lots are ST + try std.testing.expect(!agg.lt.isEmpty()); + try std.testing.expectEqual(@as(usize, 1), agg.lt.lot_count); + try std.testing.expectApproxEqAbs(@as(f64, 0.2), agg.lt.shares, 0.001); + try std.testing.expectEqual(@as(usize, 2), agg.st.lot_count); + try std.testing.expectApproxEqAbs(@as(f64, 0.8), agg.st.shares, 0.001); + // Avg cost + try std.testing.expectApproxEqAbs(@as(f64, 200.0), agg.lt.avgCost(), 0.01); +} + +test "aggregateDripLots empty" { + const lots = [_]Lot{ + .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 210, .drip = false }, + }; + const agg = aggregateDripLots(&lots); + try std.testing.expect(agg.st.isEmpty()); + try std.testing.expect(agg.lt.isEmpty()); +} + test "lerpColor" { // t=0 returns first color const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0); @@ -960,3 +1243,96 @@ test "fmtContractLine null fields" { // Null fields should show "--" try std.testing.expect(std.mem.indexOf(u8, line, "--") != null); } + +test "fmtGainLoss positive" { + var buf: [32]u8 = undefined; + const result = fmtGainLoss(&buf, 1234.56); + try std.testing.expect(result.positive); + try std.testing.expect(std.mem.startsWith(u8, result.text, "+$")); +} + +test "fmtGainLoss negative" { + var buf: [32]u8 = undefined; + const result = fmtGainLoss(&buf, -500.00); + try std.testing.expect(!result.positive); + try std.testing.expect(std.mem.startsWith(u8, result.text, "-$")); +} + +test "fmtEarningsRow with data" { + var buf: [128]u8 = undefined; + const e = EarningsEvent{ + .symbol = "AAPL", + .date = Date.fromYmd(2024, 1, 25), + .quarter = 1, + .estimate = 2.10, + .actual = 2.18, + .surprise = 0.08, + .surprise_percent = 3.8, + }; + const result = fmtEarningsRow(&buf, e); + try std.testing.expect(!result.is_future); + try std.testing.expect(result.is_positive); + try std.testing.expect(std.mem.indexOf(u8, result.text, "Q1") != null); + try std.testing.expect(std.mem.indexOf(u8, result.text, "$2.10") != null); + try std.testing.expect(std.mem.indexOf(u8, result.text, "$2.18") != null); +} + +test "fmtEarningsRow future event" { + var buf: [128]u8 = undefined; + const e = EarningsEvent{ + .symbol = "AAPL", + .date = Date.fromYmd(2026, 7, 1), + .estimate = 2.50, + }; + const result = fmtEarningsRow(&buf, e); + try std.testing.expect(result.is_future); + try std.testing.expect(std.mem.indexOf(u8, result.text, "--") != null); // no actual +} + +test "buildBlockBar" { + var buf: [256]u8 = undefined; + // Full bar + const full = buildBlockBar(&buf, 1.0, 10); + try std.testing.expectEqual(@as(usize, 30), full.len); // 10 chars * 3 bytes each + // Empty bar + const empty = buildBlockBar(&buf, 0.0, 10); + try std.testing.expectEqual(@as(usize, 10), empty.len); // 10 spaces + // Half bar: 5 full blocks + 5 spaces = 5*3 + 5 = 20 + const half = buildBlockBar(&buf, 0.5, 10); + try std.testing.expectEqual(@as(usize, 20), half.len); +} + +test "fmtHistoricalChange" { + var buf: [16]u8 = undefined; + try std.testing.expectEqualStrings("--", fmtHistoricalChange(&buf, 0, 0)); + const pos = fmtHistoricalChange(&buf, 5, 12.3); + try std.testing.expect(std.mem.startsWith(u8, pos, "+")); + try std.testing.expect(std.mem.indexOf(u8, pos, "12.3%") != null); + const neg = fmtHistoricalChange(&buf, 5, -5.2); + try std.testing.expect(std.mem.indexOf(u8, neg, "-5.2%") != null); +} + +test "fmtCandleRow" { + var buf: [128]u8 = undefined; + const candle = Candle{ + .date = Date.fromYmd(2024, 6, 15), + .open = 150.00, + .high = 155.00, + .low = 149.00, + .close = 153.00, + .adj_close = 153.00, + .volume = 50_000_000, + }; + const row = fmtCandleRow(&buf, candle); + try std.testing.expect(std.mem.indexOf(u8, row, "150.00") != null); + try std.testing.expect(std.mem.indexOf(u8, row, "155.00") != null); + try std.testing.expect(std.mem.indexOf(u8, row, "50,000,000") != null); +} + +test "fmtPriceChange" { + var buf: [32]u8 = undefined; + const pos = fmtPriceChange(&buf, 3.50, 2.04); + try std.testing.expect(std.mem.startsWith(u8, pos, "+$3.50")); + const neg = fmtPriceChange(&buf, -2.00, -1.5); + try std.testing.expect(std.mem.startsWith(u8, neg, "-$2.00")); +} diff --git a/src/tui.zig b/src/tui.zig index cf36368..c813d97 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1089,27 +1089,12 @@ const App = struct { } self.candle_last_date = latest_date; - // Build fallback prices for symbols that failed API fetch: - // 1. Use manual price:: from SRF if available - // 2. Otherwise use position avg_cost so the position still appears - var manual_price_set = std.StringHashMap(void).init(self.allocator); + // Build fallback prices for symbols that failed API fetch + var manual_price_set = zfin.risk.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch { + self.setStatus("Error building fallback prices"); + return; + }; defer manual_price_set.deinit(); - for (pf.lots) |lot| { - if (lot.lot_type != .stock) continue; - const sym = lot.priceSymbol(); - if (lot.price) |p| { - if (!prices.contains(sym)) { - prices.put(sym, p) catch {}; - manual_price_set.put(sym, {}) catch {}; - } - } - } - for (positions) |pos| { - if (!prices.contains(pos.symbol) and pos.shares > 0) { - prices.put(pos.symbol, pos.avg_cost) catch {}; - manual_price_set.put(pos.symbol, {}) catch {}; - } - } var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) catch { self.setStatus("Error computing portfolio summary"); @@ -1123,25 +1108,7 @@ const App = struct { } // 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; - } - } + summary.adjustForNonStockAssets(pf); self.portfolio_summary = summary; @@ -1300,59 +1267,32 @@ const App = struct { } // Build ST and LT DRIP summaries - var st_lots: usize = 0; - var st_shares: f64 = 0; - var st_cost: f64 = 0; - var st_first: ?zfin.Date = null; - var st_last: ?zfin.Date = null; - var lt_lots: usize = 0; - var lt_shares: f64 = 0; - var lt_cost: f64 = 0; - var lt_first: ?zfin.Date = null; - var lt_last: ?zfin.Date = null; + const drip = fmt.aggregateDripLots(matching.items); - for (matching.items) |lot| { - if (!lot.drip) continue; - const is_lt = std.mem.eql(u8, fmt.capitalGainsIndicator(lot.open_date), "LT"); - if (is_lt) { - lt_lots += 1; - lt_shares += lot.shares; - lt_cost += lot.costBasis(); - if (lt_first == null or lot.open_date.days < lt_first.?.days) lt_first = lot.open_date; - if (lt_last == null or lot.open_date.days > lt_last.?.days) lt_last = lot.open_date; - } else { - st_lots += 1; - st_shares += lot.shares; - st_cost += lot.costBasis(); - if (st_first == null or lot.open_date.days < st_first.?.days) st_first = lot.open_date; - if (st_last == null or lot.open_date.days > st_last.?.days) st_last = lot.open_date; - } - } - - if (st_lots > 0) { + if (!drip.st.isEmpty()) { self.portfolio_rows.append(self.allocator, .{ .kind = .drip_summary, .symbol = a.symbol, .pos_idx = i, .drip_is_lt = false, - .drip_lot_count = st_lots, - .drip_shares = st_shares, - .drip_avg_cost = if (st_shares > 0) st_cost / st_shares else 0, - .drip_date_first = st_first, - .drip_date_last = st_last, + .drip_lot_count = drip.st.lot_count, + .drip_shares = drip.st.shares, + .drip_avg_cost = drip.st.avgCost(), + .drip_date_first = drip.st.first_date, + .drip_date_last = drip.st.last_date, }) catch {}; } - if (lt_lots > 0) { + if (!drip.lt.isEmpty()) { self.portfolio_rows.append(self.allocator, .{ .kind = .drip_summary, .symbol = a.symbol, .pos_idx = i, .drip_is_lt = true, - .drip_lot_count = lt_lots, - .drip_shares = lt_shares, - .drip_avg_cost = if (lt_shares > 0) lt_cost / lt_shares else 0, - .drip_date_first = lt_first, - .drip_date_last = lt_last, + .drip_lot_count = drip.lt.lot_count, + .drip_shares = drip.lt.shares, + .drip_avg_cost = drip.lt.avgCost(), + .drip_date_first = drip.lt.first_date, + .drip_date_last = drip.lt.last_date, }) catch {}; } } @@ -1430,14 +1370,7 @@ const App = struct { 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); + std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn); for (cd_lots.items) |lot| { self.portfolio_rows.append(self.allocator, .{ .kind = .cd_row, @@ -1783,24 +1716,11 @@ const App = struct { self.candle_last_date = latest_date; // Build fallback prices for reload path - var manual_price_set = std.StringHashMap(void).init(self.allocator); + var manual_price_set = zfin.risk.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch { + self.setStatus("Error building fallback prices"); + return; + }; defer manual_price_set.deinit(); - for (pf.lots) |lot| { - if (lot.lot_type != .stock) continue; - const sym = lot.priceSymbol(); - if (lot.price) |p| { - if (!prices.contains(sym)) { - prices.put(sym, p) catch {}; - manual_price_set.put(sym, {}) catch {}; - } - } - } - for (positions) |pos| { - if (!prices.contains(pos.symbol) and pos.shares > 0) { - prices.put(pos.symbol, pos.avg_cost) catch {}; - manual_price_set.put(pos.symbol, {}) catch {}; - } - } var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) catch { self.setStatus("Error computing portfolio summary"); @@ -1814,20 +1734,7 @@ const App = struct { } // Include non-stock assets - const cash_total = pf.totalCash(); - const cd_total_val = pf.totalCdFaceValue(); - const opt_total = pf.totalOptionCost(); - const non_stock = cash_total + cd_total_val + 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; - } - if (summary.total_value > 0) { - for (summary.allocations) |*a| { - a.weight = a.market_value / summary.total_value; - } - } + summary.adjustForNonStockAssets(pf); self.portfolio_summary = summary; @@ -2114,16 +2021,9 @@ const App = struct { var hist_parts: [6][]const u8 = undefined; for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| { const snap = snapshots[pi]; - if (snap.position_count == 0) { - hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: --", .{period.label()}); - } else { - const pct = snap.changePct(); - if (pct >= 0) { - hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: +{d:.1}%", .{ period.label(), pct }); - } else { - hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: {d:.1}%", .{ period.label(), pct }); - } - } + var hbuf: [16]u8 = undefined; + const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct()); + hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: {s}", .{ period.label(), change_str }); } const hist_text = try std.fmt.allocPrint(arena, " Historical: {s} {s} {s} {s} {s} {s}", .{ hist_parts[0], hist_parts[1], hist_parts[2], hist_parts[3], hist_parts[4], hist_parts[5], @@ -2544,12 +2444,9 @@ const App = struct { if (q.previous_close > 0) { const change = q.close - q.previous_close; const pct = (change / q.previous_close) * 100.0; + var chg_buf: [64]u8 = undefined; const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle(); - if (change >= 0) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ change, pct }), .style = change_style }); - } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -change, pct }), .style = change_style }); - } + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style }); } } else if (c.len > 0) { const last = c[c.len - 1]; @@ -2559,12 +2456,9 @@ const App = struct { const prev_close = c[c.len - 2].close; const change = last.close - prev_close; const pct = (change / prev_close) * 100.0; + var chg_buf: [64]u8 = undefined; const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle(); - if (change >= 0) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ change, pct }), .style = change_style }); - } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -change, pct }), .style = change_style }); - } + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style }); } } @@ -2844,10 +2738,10 @@ const App = struct { // No candle data but have a quote - show it var qclose_buf: [24]u8 = undefined; try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoney(&qclose_buf, q.close)}), .style = th.contentStyle() }); - if (q.change >= 0) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ q.change, q.percent_change }), .style = th.positiveStyle() }); - } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -q.change, q.percent_change }), .style = th.negativeStyle() }); + { + var chg_buf: [64]u8 = undefined; + const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle(); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style }); } return lines.toOwnedSlice(arena); } @@ -2879,12 +2773,9 @@ const App = struct { const start_idx = if (c.len > 20) c.len - 20 else 0; for (c[start_idx..]) |candle| { - var db: [10]u8 = undefined; - var vb: [32]u8 = undefined; + var row_buf: [128]u8 = undefined; const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle(); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}", .{ - candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume), - }), .style = day_change }); + try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change }); } return lines.toOwnedSlice(arena); @@ -2941,12 +2832,9 @@ const App = struct { if (prev_close > 0) { const change = price - prev_close; const pct = (change / prev_close) * 100.0; + var chg_buf: [64]u8 = undefined; const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle(); - if (change >= 0) { - try col1.add(arena, try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ change, pct }), change_style); - } else { - try col1.add(arena, try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -change, pct }), change_style); - } + try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), change_style); } // Columns 2-4: ETF profile (only for actual ETFs) @@ -3172,37 +3060,33 @@ const App = struct { } const price_arr = [4]?zfin.performance.PerformanceResult{ price.one_year, price.three_year, price.five_year, price.ten_year }; + const total_arr_vals: [4]?zfin.performance.PerformanceResult = if (total) |t| + .{ t.one_year, t.three_year, t.five_year, t.ten_year } + else + .{ null, null, null, null }; const labels = [4][]const u8{ "1-Year Return:", "3-Year Return:", "5-Year Return:", "10-Year Return:" }; const annualize = [4]bool{ false, true, true, true }; for (0..4) |i| { - var price_str: [16]u8 = undefined; - var price_val: f64 = 0; - const ps = if (price_arr[i]) |r| blk: { - const val = if (annualize[i]) r.annualized_return orelse r.total_return else r.total_return; - price_val = val; - break :blk zfin.performance.formatReturn(&price_str, val); - } else "N/A"; + var price_buf: [32]u8 = undefined; + var total_buf: [32]u8 = undefined; + const row = fmt.fmtReturnsRow( + &price_buf, + &total_buf, + price_arr[i], + if (has_total) total_arr_vals[i] else null, + annualize[i], + ); const row_style = if (price_arr[i] != null) - (if (price_val >= 0) th.positiveStyle() else th.negativeStyle()) + (if (row.price_positive) th.positiveStyle() else th.negativeStyle()) else th.mutedStyle(); if (has_total) { - const t = total.?; - const total_arr = [4]?zfin.performance.PerformanceResult{ t.one_year, t.three_year, t.five_year, t.ten_year }; - var total_str: [16]u8 = undefined; - const ts = if (total_arr[i]) |r| blk: { - const val = if (annualize[i]) r.annualized_return orelse r.total_return else r.total_return; - break :blk zfin.performance.formatReturn(&total_str, val); - } else "N/A"; - - const suffix: []const u8 = if (annualize[i]) " ann." else ""; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}{s}", .{ labels[i], ps, ts, suffix }), .style = row_style }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}{s}", .{ labels[i], row.price_str, row.total_str orelse "N/A", row.suffix }), .style = row_style }); } else { - const suffix: []const u8 = if (annualize[i]) " ann." else ""; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}{s}", .{ labels[i], ps, suffix }), .style = row_style }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}{s}", .{ labels[i], row.price_str, row.suffix }), .style = row_style }); } } } @@ -3414,47 +3298,11 @@ const App = struct { } /// Build a bar using Unicode block elements for sub-character precision. - /// U+2588 █ full, U+2589 ▉ 7/8, U+258A ▊ 3/4, U+258B ▋ 5/8, - /// U+258C ▌ 1/2, U+258D ▍ 3/8, U+258E ▎ 1/4, U+258F ▏ 1/8 + /// Wraps fmt.buildBlockBar into arena-allocated memory. fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 { - // Each character has 8 sub-positions - const total_eighths: f64 = @as(f64, @floatFromInt(total_chars)) * 8.0; - const filled_eighths_f = weight * total_eighths; - const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths)); - const full_blocks = filled_eighths / 8; - const partial = filled_eighths % 8; - - // Each full block is 3 bytes UTF-8, partial is 3 bytes, spaces are 1 byte - const has_partial: usize = if (partial > 0) 1 else 0; - const empty_blocks = total_chars - full_blocks - has_partial; - const byte_len = full_blocks * 3 + has_partial * 3 + empty_blocks; - var buf = try arena.alloc(u8, byte_len); - var pos: usize = 0; - - // Full blocks: U+2588 = E2 96 88 - for (0..full_blocks) |_| { - buf[pos] = 0xE2; - buf[pos + 1] = 0x96; - buf[pos + 2] = 0x88; - pos += 3; - } - - // Partial block (if any) - // U+2588..U+258F: full=0x88, 7/8=0x89, 3/4=0x8A, 5/8=0x8B, - // 1/2=0x8C, 3/8=0x8D, 1/4=0x8E, 1/8=0x8F - // partial eighths: 7->0x89, 6->0x8A, 5->0x8B, 4->0x8C, 3->0x8D, 2->0x8E, 1->0x8F - if (partial > 0) { - const code: u8 = 0x88 + @as(u8, @intCast(8 - partial)); - buf[pos] = 0xE2; - buf[pos + 1] = 0x96; - buf[pos + 2] = code; - pos += 3; - } - - // Empty spaces - @memset(buf[pos..], ' '); - - return buf; + var buf: [256]u8 = undefined; + const result = fmt.buildBlockBar(&buf, weight, total_chars); + return arena.dupe(u8, result); } // ── Help ───────────────────────────────────────────────────── @@ -3926,37 +3774,11 @@ fn renderEarningsLines( }), .style = th.mutedStyle() }); for (ev) |e| { - var db: [10]u8 = undefined; - const date_str = e.date.format(&db); + var row_buf: [128]u8 = undefined; + const row = fmt.fmtEarningsRow(&row_buf, e); - var q_buf: [4]u8 = undefined; - const q_str = if (e.quarter) |q| std.fmt.bufPrint(&q_buf, "Q{d}", .{q}) catch "--" else "--"; - - var est_buf: [12]u8 = undefined; - const est_str = if (e.estimate) |est| std.fmt.bufPrint(&est_buf, "${d:.2}", .{est}) catch "--" else "--"; - - var act_buf: [12]u8 = undefined; - const act_str = if (e.actual) |act| std.fmt.bufPrint(&act_buf, "${d:.2}", .{act}) catch "--" else "--"; - - var surp_buf: [12]u8 = undefined; - const surp_str = if (e.surpriseAmount()) |s| - (if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?") - else - @as([]const u8, "--"); - - var surp_pct_buf: [12]u8 = undefined; - const surp_pct_str = if (e.surprisePct()) |sp| - (if (sp >= 0) std.fmt.bufPrint(&surp_pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&surp_pct_buf, "{d:.1}%", .{sp}) catch "?") - else - @as([]const u8, "--"); - - const text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{ - date_str, q_str, est_str, act_str, surp_str, surp_pct_str, - }); - - // Color by surprise - const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true; - const row_style = if (e.isFuture()) th.mutedStyle() else if (surprise_positive) th.positiveStyle() else th.negativeStyle(); + const text = try std.fmt.allocPrint(arena, " {s}", .{row.text}); + const row_style = if (row.is_future) th.mutedStyle() else if (row.is_positive) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = text, .style = row_style }); }