ai: deduplicate cli/tli display functions

This commit is contained in:
Emil Lerch 2026-03-01 11:57:29 -08:00
parent a582228765
commit facc233976
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 615 additions and 439 deletions

View file

@ -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);
}

View file

@ -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) });
}

View file

@ -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", .{});

View file

@ -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", .{});

View file

@ -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| {

View file

@ -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});

View file

@ -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"));
}

View file

@ -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 });
}