ai: deduplicate cli/tli display functions
This commit is contained in:
parent
a582228765
commit
facc233976
8 changed files with 615 additions and 439 deletions
|
|
@ -95,6 +95,29 @@ pub const PortfolioSummary = struct {
|
||||||
pub fn deinit(self: *PortfolioSummary, allocator: std.mem.Allocator) void {
|
pub fn deinit(self: *PortfolioSummary, allocator: std.mem.Allocator) void {
|
||||||
allocator.free(self.allocations);
|
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 {
|
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 ───────────────────────────────
|
// ── Historical portfolio value ───────────────────────────────
|
||||||
|
|
||||||
/// A lookback period for historical portfolio value.
|
/// A lookback period for historical portfolio value.
|
||||||
|
|
@ -496,3 +552,61 @@ test "computeRisk insufficient data" {
|
||||||
// Less than 21 candles -> returns null
|
// Less than 21 candles -> returns null
|
||||||
try std.testing.expect(computeRisk(&candles) == 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,36 +26,20 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
|
||||||
// Build prices map from cache
|
// Build prices map from cache
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
defer prices.deinit();
|
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| {
|
for (positions) |pos| {
|
||||||
if (pos.shares <= 0) continue;
|
if (pos.shares <= 0) continue;
|
||||||
if (svc.getCachedCandles(pos.symbol)) |cs| {
|
if (svc.getCachedCandles(pos.symbol)) |cs| {
|
||||||
defer allocator.free(cs);
|
defer allocator.free(cs);
|
||||||
if (cs.len > 0) {
|
if (cs.len > 0) {
|
||||||
try prices.put(pos.symbol, cs[cs.len - 1].close);
|
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
|
// Build fallback prices for symbols without cached candle data
|
||||||
for (positions) |pos| {
|
var manual_price_set = try zfin.risk.buildFallbackPrices(allocator, portfolio.lots, positions, &prices);
|
||||||
if (!prices.contains(pos.symbol) and pos.shares > 0) {
|
defer manual_price_set.deinit();
|
||||||
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 {
|
var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch {
|
||||||
try cli.stderrPrint("Error computing portfolio summary.\n");
|
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);
|
defer summary.deinit(allocator);
|
||||||
|
|
||||||
// Include non-stock assets in grand total (same as portfolio command)
|
// Include non-stock assets in grand total (same as portfolio command)
|
||||||
const cash_total = portfolio.totalCash();
|
summary.adjustForNonStockAssets(portfolio);
|
||||||
const cd_total = portfolio.totalCdFaceValue();
|
|
||||||
const opt_total = portfolio.totalOptionCost();
|
|
||||||
const non_stock = cash_total + cd_total + opt_total;
|
|
||||||
summary.total_value += non_stock;
|
|
||||||
|
|
||||||
// Load classification metadata
|
// Load classification metadata
|
||||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0;
|
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 {
|
pub fn display(result: zfin.analysis.AnalysisResult, file_path: []const u8, color: bool, out: *std.Io.Writer) !void {
|
||||||
const label_width: usize = 24;
|
const label_width = fmt.analysis_label_width;
|
||||||
const bar_width: usize = 30;
|
const bar_width = fmt.analysis_bar_width;
|
||||||
|
|
||||||
try cli.setBold(out, color);
|
try cli.setBold(out, color);
|
||||||
try out.print("\nPortfolio Analysis ({s})\n", .{file_path});
|
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.
|
/// 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 {
|
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| {
|
for (items) |item| {
|
||||||
var val_buf: [24]u8 = undefined;
|
var val_buf: [24]u8 = undefined;
|
||||||
const pct = item.weight * 100.0;
|
const pct = item.weight * 100.0;
|
||||||
|
|
||||||
// Compute filled eighths
|
// Build bar using shared function
|
||||||
const total_eighths: f64 = @as(f64, @floatFromInt(bar_width)) * 8.0;
|
var bar_buf: [256]u8 = undefined;
|
||||||
const filled_eighths_f = item.weight * total_eighths;
|
const bar = fmt.buildBlockBar(&bar_buf, item.weight, bar_width);
|
||||||
const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths));
|
|
||||||
const full_count = filled_eighths / 8;
|
|
||||||
const partial = filled_eighths % 8;
|
|
||||||
|
|
||||||
// Padded label
|
// Padded label
|
||||||
const lbl_len = @min(item.label.len, label_width);
|
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(" ");
|
try out.writeAll(" ");
|
||||||
if (color) try fmt.ansiSetFg(out, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.CLR_ACCENT[2]);
|
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);
|
try out.writeAll(bar);
|
||||||
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(" ");
|
|
||||||
}
|
|
||||||
if (color) try fmt.ansiReset(out);
|
if (color) try fmt.ansiReset(out);
|
||||||
try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoney(&val_buf, item.value) });
|
try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoney(&val_buf, item.value) });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,36 +44,18 @@ pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bo
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
|
|
||||||
for (events) |e| {
|
for (events) |e| {
|
||||||
var db: [10]u8 = undefined;
|
var row_buf: [128]u8 = undefined;
|
||||||
const is_future = e.isFuture();
|
const row = fmt.fmtEarningsRow(&row_buf, e);
|
||||||
const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true;
|
|
||||||
|
|
||||||
if (is_future) {
|
if (row.is_future) {
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
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);
|
try cli.setFg(out, color, cli.CLR_POSITIVE);
|
||||||
} else {
|
} else {
|
||||||
try cli.setFg(out, color, cli.CLR_NEGATIVE);
|
try cli.setFg(out, color, cli.CLR_NEGATIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
try out.print("{s:>12}", .{e.date.format(&db)});
|
try out.print("{s}", .{row.text});
|
||||||
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:>5}", .{@tagName(e.report_time)});
|
try out.print(" {s:>5}", .{@tagName(e.report_time)});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
|
|
|
||||||
|
|
@ -108,24 +108,28 @@ pub fn printReturnsTable(
|
||||||
for (periods, 0..) |period, i| {
|
for (periods, 0..) |period, i| {
|
||||||
try out.print(" {s:<20}", .{period.label});
|
try out.print(" {s:<20}", .{period.label});
|
||||||
|
|
||||||
if (price_arr[i]) |r| {
|
var price_buf: [32]u8 = undefined;
|
||||||
var rb: [32]u8 = undefined;
|
var total_buf: [32]u8 = undefined;
|
||||||
const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return;
|
const row = fmt.fmtReturnsRow(
|
||||||
try cli.setGainLoss(out, color, val);
|
&price_buf,
|
||||||
try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)});
|
&total_buf,
|
||||||
try cli.reset(out, color);
|
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 {
|
} else {
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
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 (has_total) {
|
||||||
if (total_arr[i]) |r| {
|
if (row.total_str) |ts| {
|
||||||
var rb: [32]u8 = undefined;
|
try cli.setGainLoss(out, color, if (row.price_positive) @as(f64, 1) else @as(f64, -1));
|
||||||
const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return;
|
try out.print(" {s:>13}", .{ts});
|
||||||
try cli.setGainLoss(out, color, val);
|
|
||||||
try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)});
|
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
} else {
|
} else {
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
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 cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
try out.print(" ann.", .{});
|
try out.print("{s}", .{row.suffix});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
}
|
}
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
|
|
|
||||||
|
|
@ -151,29 +151,9 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute summary
|
// Compute summary
|
||||||
// Build fallback prices for symbols that failed API fetch:
|
// Build fallback prices for symbols that failed API fetch
|
||||||
// 1. Use manual price:: from SRF if available
|
var manual_price_set = try zfin.risk.buildFallbackPrices(allocator, portfolio.lots, positions, &prices);
|
||||||
// 2. Otherwise use position avg_cost (open_price) so the position still appears
|
|
||||||
var manual_price_set = std.StringHashMap(void).init(allocator);
|
|
||||||
defer manual_price_set.deinit();
|
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 {
|
var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch {
|
||||||
try cli.stderrPrint("Error computing portfolio summary.\n");
|
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);
|
}.f);
|
||||||
|
|
||||||
// Include non-stock assets in the grand total
|
// Include non-stock assets in the grand total
|
||||||
const cash_total = portfolio.totalCash();
|
summary.adjustForNonStockAssets(portfolio);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build candle map once for historical snapshots and risk metrics.
|
// Build candle map once for historical snapshots and risk metrics.
|
||||||
// This avoids parsing the full candle history multiple times.
|
// This avoids parsing the full candle history multiple times.
|
||||||
|
|
@ -353,17 +319,10 @@ pub fn display(
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| {
|
for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| {
|
||||||
const snap = snapshots[pi];
|
const snap = snapshots[pi];
|
||||||
if (snap.position_count == 0) {
|
var hbuf: [16]u8 = undefined;
|
||||||
try out.print(" {s}: --", .{period.label()});
|
const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct());
|
||||||
} else {
|
if (snap.position_count > 0) try cli.setGainLoss(out, color, snap.changePct());
|
||||||
const pct = snap.changePct();
|
try out.print(" {s}: {s}", .{ period.label(), change_str });
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (pi < zfin.risk.HistoricalPeriod.all.len - 1) try out.print(" ", .{});
|
if (pi < zfin.risk.HistoricalPeriod.all.len - 1) try out.print(" ", .{});
|
||||||
}
|
}
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
|
|
@ -469,60 +428,33 @@ pub fn display(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summarize DRIP lots as ST/LT
|
// Summarize DRIP lots as ST/LT
|
||||||
var st_lots: usize = 0;
|
const drip = fmt.aggregateDripLots(lots_for_sym.items);
|
||||||
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;
|
|
||||||
|
|
||||||
for (lots_for_sym.items) |lot| {
|
if (!drip.st.isEmpty()) {
|
||||||
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) {
|
|
||||||
var avg_buf: [24]u8 = undefined;
|
var avg_buf: [24]u8 = undefined;
|
||||||
var d1_buf: [10]u8 = undefined;
|
var d1_buf: [10]u8 = undefined;
|
||||||
var d2_buf: [10]u8 = undefined;
|
var d2_buf: [10]u8 = undefined;
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
|
try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
|
||||||
st_lots,
|
drip.st.lot_count,
|
||||||
st_shares,
|
drip.st.shares,
|
||||||
fmt.fmtMoney2(&avg_buf, if (st_shares > 0) st_cost / st_shares else 0),
|
fmt.fmtMoney2(&avg_buf, drip.st.avgCost()),
|
||||||
if (st_first) |d| d.format(&d1_buf)[0..7] else "?",
|
if (drip.st.first_date) |d| d.format(&d1_buf)[0..7] else "?",
|
||||||
if (st_last) |d| d.format(&d2_buf)[0..7] else "?",
|
if (drip.st.last_date) |d| d.format(&d2_buf)[0..7] else "?",
|
||||||
});
|
});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
}
|
}
|
||||||
if (lt_lots > 0) {
|
if (!drip.lt.isEmpty()) {
|
||||||
var avg_buf2: [24]u8 = undefined;
|
var avg_buf2: [24]u8 = undefined;
|
||||||
var d1_buf2: [10]u8 = undefined;
|
var d1_buf2: [10]u8 = undefined;
|
||||||
var d2_buf2: [10]u8 = undefined;
|
var d2_buf2: [10]u8 = undefined;
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
try out.print(" LT: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
|
try out.print(" LT: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
|
||||||
lt_lots,
|
drip.lt.lot_count,
|
||||||
lt_shares,
|
drip.lt.shares,
|
||||||
fmt.fmtMoney2(&avg_buf2, if (lt_shares > 0) lt_cost / lt_shares else 0),
|
fmt.fmtMoney2(&avg_buf2, drip.lt.avgCost()),
|
||||||
if (lt_first) |d| d.format(&d1_buf2)[0..7] else "?",
|
if (drip.lt.first_date) |d| d.format(&d1_buf2)[0..7] else "?",
|
||||||
if (lt_last) |d| d.format(&d2_buf2)[0..7] else "?",
|
if (drip.lt.last_date) |d| d.format(&d2_buf2)[0..7] else "?",
|
||||||
});
|
});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
}
|
}
|
||||||
|
|
@ -631,14 +563,7 @@ pub fn display(
|
||||||
try cd_lots.append(allocator, lot);
|
try cd_lots.append(allocator, lot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
std.mem.sort(zfin.Lot, cd_lots.items, {}, struct {
|
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
|
||||||
fn f(ctx: void, a: zfin.Lot, b: zfin.Lot) bool {
|
|
||||||
_ = ctx;
|
|
||||||
const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32);
|
|
||||||
const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32);
|
|
||||||
return ad < bd;
|
|
||||||
}
|
|
||||||
}.f);
|
|
||||||
|
|
||||||
var cd_section_total: f64 = 0;
|
var cd_section_total: f64 = 0;
|
||||||
for (cd_lots.items) |lot| {
|
for (cd_lots.items) |lot| {
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,9 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
|
||||||
if (prev_close > 0) {
|
if (prev_close > 0) {
|
||||||
const change = price - prev_close;
|
const change = price - prev_close;
|
||||||
const pct = (change / prev_close) * 100.0;
|
const pct = (change / prev_close) * 100.0;
|
||||||
|
var chg_buf: [64]u8 = undefined;
|
||||||
try cli.setGainLoss(out, color, change);
|
try cli.setGainLoss(out, color, change);
|
||||||
if (change >= 0) {
|
try out.print(" Change: {s}\n", .{fmt.fmtPriceChange(&chg_buf, change, pct)});
|
||||||
try out.print(" Change: +${d:.2} (+{d:.2}%)\n", .{ change, pct });
|
|
||||||
} else {
|
|
||||||
try out.print(" Change: -${d:.2} ({d:.2}%)\n", .{ -change, pct });
|
|
||||||
}
|
|
||||||
try cli.reset(out, color);
|
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;
|
const start_idx = if (candles.len > 20) candles.len - 20 else 0;
|
||||||
for (candles[start_idx..]) |candle| {
|
for (candles[start_idx..]) |candle| {
|
||||||
var db: [10]u8 = undefined;
|
var row_buf: [128]u8 = undefined;
|
||||||
var vb: [32]u8 = undefined;
|
|
||||||
const day_gain = candle.close >= candle.open;
|
const day_gain = candle.close >= candle.open;
|
||||||
try cli.setGainLoss(out, color, if (day_gain) @as(f64, 1) else @as(f64, -1));
|
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", .{
|
try out.print("{s}\n", .{fmt.fmtCandleRow(&row_buf, candle)});
|
||||||
candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume),
|
|
||||||
});
|
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
}
|
}
|
||||||
try out.print("\n {d} trading days shown\n", .{candles[start_idx..].len});
|
try out.print("\n {d} trading days shown\n", .{candles[start_idx..].len});
|
||||||
|
|
|
||||||
378
src/format.zig
378
src/format.zig
|
|
@ -6,8 +6,11 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const Date = @import("models/date.zig").Date;
|
const Date = @import("models/date.zig").Date;
|
||||||
const Candle = @import("models/candle.zig").Candle;
|
const Candle = @import("models/candle.zig").Candle;
|
||||||
const OptionContract = @import("models/option.zig").OptionContract;
|
|
||||||
const Lot = @import("models/portfolio.zig").Lot;
|
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 ─────────────────────────────────────────
|
// ── 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
|
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 ────────────────────────────────────────────
|
// ── 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].
|
/// 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 {
|
pub fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 {
|
||||||
return .{
|
return .{
|
||||||
|
|
@ -851,6 +1074,66 @@ test "lotSortFn" {
|
||||||
try std.testing.expect(!lotSortFn({}, open_old, open_new));
|
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" {
|
test "lerpColor" {
|
||||||
// t=0 returns first color
|
// t=0 returns first color
|
||||||
const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0);
|
const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0);
|
||||||
|
|
@ -960,3 +1243,96 @@ test "fmtContractLine null fields" {
|
||||||
// Null fields should show "--"
|
// Null fields should show "--"
|
||||||
try std.testing.expect(std.mem.indexOf(u8, line, "--") != null);
|
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"));
|
||||||
|
}
|
||||||
|
|
|
||||||
306
src/tui.zig
306
src/tui.zig
|
|
@ -1089,27 +1089,12 @@ const App = struct {
|
||||||
}
|
}
|
||||||
self.candle_last_date = latest_date;
|
self.candle_last_date = latest_date;
|
||||||
|
|
||||||
// Build fallback prices for symbols that failed API fetch:
|
// Build fallback prices for symbols that failed API fetch
|
||||||
// 1. Use manual price:: from SRF if available
|
var manual_price_set = zfin.risk.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch {
|
||||||
// 2. Otherwise use position avg_cost so the position still appears
|
self.setStatus("Error building fallback prices");
|
||||||
var manual_price_set = std.StringHashMap(void).init(self.allocator);
|
return;
|
||||||
|
};
|
||||||
defer manual_price_set.deinit();
|
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 {
|
var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) catch {
|
||||||
self.setStatus("Error computing portfolio summary");
|
self.setStatus("Error computing portfolio summary");
|
||||||
|
|
@ -1123,25 +1108,7 @@ const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include non-stock assets in the grand total
|
// Include non-stock assets in the grand total
|
||||||
// Cash and CDs add equally to value and cost (no gain/loss),
|
summary.adjustForNonStockAssets(pf);
|
||||||
// options add at cost basis (no live pricing).
|
|
||||||
// This keeps unrealized_pnl correct (only stocks contribute market gains)
|
|
||||||
// but dilutes the return% against the full portfolio cost base.
|
|
||||||
const cash_total = pf.totalCash();
|
|
||||||
const cd_total = pf.totalCdFaceValue();
|
|
||||||
const opt_total = pf.totalOptionCost();
|
|
||||||
const non_stock = cash_total + cd_total + opt_total;
|
|
||||||
summary.total_value += non_stock;
|
|
||||||
summary.total_cost += non_stock;
|
|
||||||
if (summary.total_cost > 0) {
|
|
||||||
summary.unrealized_return = summary.unrealized_pnl / summary.total_cost;
|
|
||||||
}
|
|
||||||
// Reweight allocations against grand total
|
|
||||||
if (summary.total_value > 0) {
|
|
||||||
for (summary.allocations) |*a| {
|
|
||||||
a.weight = a.market_value / summary.total_value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.portfolio_summary = summary;
|
self.portfolio_summary = summary;
|
||||||
|
|
||||||
|
|
@ -1300,59 +1267,32 @@ const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build ST and LT DRIP summaries
|
// Build ST and LT DRIP summaries
|
||||||
var st_lots: usize = 0;
|
const drip = fmt.aggregateDripLots(matching.items);
|
||||||
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;
|
|
||||||
|
|
||||||
for (matching.items) |lot| {
|
if (!drip.st.isEmpty()) {
|
||||||
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) {
|
|
||||||
self.portfolio_rows.append(self.allocator, .{
|
self.portfolio_rows.append(self.allocator, .{
|
||||||
.kind = .drip_summary,
|
.kind = .drip_summary,
|
||||||
.symbol = a.symbol,
|
.symbol = a.symbol,
|
||||||
.pos_idx = i,
|
.pos_idx = i,
|
||||||
.drip_is_lt = false,
|
.drip_is_lt = false,
|
||||||
.drip_lot_count = st_lots,
|
.drip_lot_count = drip.st.lot_count,
|
||||||
.drip_shares = st_shares,
|
.drip_shares = drip.st.shares,
|
||||||
.drip_avg_cost = if (st_shares > 0) st_cost / st_shares else 0,
|
.drip_avg_cost = drip.st.avgCost(),
|
||||||
.drip_date_first = st_first,
|
.drip_date_first = drip.st.first_date,
|
||||||
.drip_date_last = st_last,
|
.drip_date_last = drip.st.last_date,
|
||||||
}) catch {};
|
}) catch {};
|
||||||
}
|
}
|
||||||
if (lt_lots > 0) {
|
if (!drip.lt.isEmpty()) {
|
||||||
self.portfolio_rows.append(self.allocator, .{
|
self.portfolio_rows.append(self.allocator, .{
|
||||||
.kind = .drip_summary,
|
.kind = .drip_summary,
|
||||||
.symbol = a.symbol,
|
.symbol = a.symbol,
|
||||||
.pos_idx = i,
|
.pos_idx = i,
|
||||||
.drip_is_lt = true,
|
.drip_is_lt = true,
|
||||||
.drip_lot_count = lt_lots,
|
.drip_lot_count = drip.lt.lot_count,
|
||||||
.drip_shares = lt_shares,
|
.drip_shares = drip.lt.shares,
|
||||||
.drip_avg_cost = if (lt_shares > 0) lt_cost / lt_shares else 0,
|
.drip_avg_cost = drip.lt.avgCost(),
|
||||||
.drip_date_first = lt_first,
|
.drip_date_first = drip.lt.first_date,
|
||||||
.drip_date_last = lt_last,
|
.drip_date_last = drip.lt.last_date,
|
||||||
}) catch {};
|
}) catch {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1430,14 +1370,7 @@ const App = struct {
|
||||||
cd_lots.append(self.allocator, lot) catch continue;
|
cd_lots.append(self.allocator, lot) catch continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
std.mem.sort(zfin.Lot, cd_lots.items, {}, struct {
|
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
|
||||||
fn f(ctx: void, a: zfin.Lot, b: zfin.Lot) bool {
|
|
||||||
_ = ctx;
|
|
||||||
const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32);
|
|
||||||
const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32);
|
|
||||||
return ad < bd;
|
|
||||||
}
|
|
||||||
}.f);
|
|
||||||
for (cd_lots.items) |lot| {
|
for (cd_lots.items) |lot| {
|
||||||
self.portfolio_rows.append(self.allocator, .{
|
self.portfolio_rows.append(self.allocator, .{
|
||||||
.kind = .cd_row,
|
.kind = .cd_row,
|
||||||
|
|
@ -1783,24 +1716,11 @@ const App = struct {
|
||||||
self.candle_last_date = latest_date;
|
self.candle_last_date = latest_date;
|
||||||
|
|
||||||
// Build fallback prices for reload path
|
// 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();
|
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 {
|
var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) catch {
|
||||||
self.setStatus("Error computing portfolio summary");
|
self.setStatus("Error computing portfolio summary");
|
||||||
|
|
@ -1814,20 +1734,7 @@ const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include non-stock assets
|
// Include non-stock assets
|
||||||
const cash_total = pf.totalCash();
|
summary.adjustForNonStockAssets(pf);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.portfolio_summary = summary;
|
self.portfolio_summary = summary;
|
||||||
|
|
||||||
|
|
@ -2114,16 +2021,9 @@ const App = struct {
|
||||||
var hist_parts: [6][]const u8 = undefined;
|
var hist_parts: [6][]const u8 = undefined;
|
||||||
for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| {
|
for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| {
|
||||||
const snap = snapshots[pi];
|
const snap = snapshots[pi];
|
||||||
if (snap.position_count == 0) {
|
var hbuf: [16]u8 = undefined;
|
||||||
hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: --", .{period.label()});
|
const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct());
|
||||||
} else {
|
hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: {s}", .{ period.label(), change_str });
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const hist_text = try std.fmt.allocPrint(arena, " Historical: {s} {s} {s} {s} {s} {s}", .{
|
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],
|
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) {
|
if (q.previous_close > 0) {
|
||||||
const change = q.close - q.previous_close;
|
const change = q.close - q.previous_close;
|
||||||
const pct = (change / q.previous_close) * 100.0;
|
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();
|
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: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (c.len > 0) {
|
} else if (c.len > 0) {
|
||||||
const last = c[c.len - 1];
|
const last = c[c.len - 1];
|
||||||
|
|
@ -2559,12 +2456,9 @@ const App = struct {
|
||||||
const prev_close = c[c.len - 2].close;
|
const prev_close = c[c.len - 2].close;
|
||||||
const change = last.close - prev_close;
|
const change = last.close - prev_close;
|
||||||
const pct = (change / prev_close) * 100.0;
|
const pct = (change / prev_close) * 100.0;
|
||||||
|
var chg_buf: [64]u8 = undefined;
|
||||||
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
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: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2844,10 +2738,10 @@ const App = struct {
|
||||||
// No candle data but have a quote - show it
|
// No candle data but have a quote - show it
|
||||||
var qclose_buf: [24]u8 = undefined;
|
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() });
|
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() });
|
var chg_buf: [64]u8 = undefined;
|
||||||
} else {
|
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -q.change, q.percent_change }), .style = 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);
|
return lines.toOwnedSlice(arena);
|
||||||
}
|
}
|
||||||
|
|
@ -2879,12 +2773,9 @@ const App = struct {
|
||||||
|
|
||||||
const start_idx = if (c.len > 20) c.len - 20 else 0;
|
const start_idx = if (c.len > 20) c.len - 20 else 0;
|
||||||
for (c[start_idx..]) |candle| {
|
for (c[start_idx..]) |candle| {
|
||||||
var db: [10]u8 = undefined;
|
var row_buf: [128]u8 = undefined;
|
||||||
var vb: [32]u8 = undefined;
|
|
||||||
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
|
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}", .{
|
try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change });
|
||||||
candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume),
|
|
||||||
}), .style = day_change });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.toOwnedSlice(arena);
|
return lines.toOwnedSlice(arena);
|
||||||
|
|
@ -2941,12 +2832,9 @@ const App = struct {
|
||||||
if (prev_close > 0) {
|
if (prev_close > 0) {
|
||||||
const change = price - prev_close;
|
const change = price - prev_close;
|
||||||
const pct = (change / prev_close) * 100.0;
|
const pct = (change / prev_close) * 100.0;
|
||||||
|
var chg_buf: [64]u8 = undefined;
|
||||||
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||||
if (change >= 0) {
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), change_style);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Columns 2-4: ETF profile (only for actual ETFs)
|
// 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 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 labels = [4][]const u8{ "1-Year Return:", "3-Year Return:", "5-Year Return:", "10-Year Return:" };
|
||||||
const annualize = [4]bool{ false, true, true, true };
|
const annualize = [4]bool{ false, true, true, true };
|
||||||
|
|
||||||
for (0..4) |i| {
|
for (0..4) |i| {
|
||||||
var price_str: [16]u8 = undefined;
|
var price_buf: [32]u8 = undefined;
|
||||||
var price_val: f64 = 0;
|
var total_buf: [32]u8 = undefined;
|
||||||
const ps = if (price_arr[i]) |r| blk: {
|
const row = fmt.fmtReturnsRow(
|
||||||
const val = if (annualize[i]) r.annualized_return orelse r.total_return else r.total_return;
|
&price_buf,
|
||||||
price_val = val;
|
&total_buf,
|
||||||
break :blk zfin.performance.formatReturn(&price_str, val);
|
price_arr[i],
|
||||||
} else "N/A";
|
if (has_total) total_arr_vals[i] else null,
|
||||||
|
annualize[i],
|
||||||
|
);
|
||||||
|
|
||||||
const row_style = if (price_arr[i] != null)
|
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
|
else
|
||||||
th.mutedStyle();
|
th.mutedStyle();
|
||||||
|
|
||||||
if (has_total) {
|
if (has_total) {
|
||||||
const t = total.?;
|
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 });
|
||||||
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 });
|
|
||||||
} else {
|
} 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], row.price_str, row.suffix }), .style = row_style });
|
||||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}{s}", .{ labels[i], ps, suffix }), .style = row_style });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3414,47 +3298,11 @@ const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a bar using Unicode block elements for sub-character precision.
|
/// 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,
|
/// Wraps fmt.buildBlockBar into arena-allocated memory.
|
||||||
/// U+258C ▌ 1/2, U+258D ▍ 3/8, U+258E ▎ 1/4, U+258F ▏ 1/8
|
|
||||||
fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 {
|
fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 {
|
||||||
// Each character has 8 sub-positions
|
var buf: [256]u8 = undefined;
|
||||||
const total_eighths: f64 = @as(f64, @floatFromInt(total_chars)) * 8.0;
|
const result = fmt.buildBlockBar(&buf, weight, total_chars);
|
||||||
const filled_eighths_f = weight * total_eighths;
|
return arena.dupe(u8, result);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Help ─────────────────────────────────────────────────────
|
// ── Help ─────────────────────────────────────────────────────
|
||||||
|
|
@ -3926,37 +3774,11 @@ fn renderEarningsLines(
|
||||||
}), .style = th.mutedStyle() });
|
}), .style = th.mutedStyle() });
|
||||||
|
|
||||||
for (ev) |e| {
|
for (ev) |e| {
|
||||||
var db: [10]u8 = undefined;
|
var row_buf: [128]u8 = undefined;
|
||||||
const date_str = e.date.format(&db);
|
const row = fmt.fmtEarningsRow(&row_buf, e);
|
||||||
|
|
||||||
var q_buf: [4]u8 = undefined;
|
const text = try std.fmt.allocPrint(arena, " {s}", .{row.text});
|
||||||
const q_str = if (e.quarter) |q| std.fmt.bufPrint(&q_buf, "Q{d}", .{q}) catch "--" else "--";
|
const row_style = if (row.is_future) th.mutedStyle() else if (row.is_positive) th.positiveStyle() else th.negativeStyle();
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
try lines.append(arena, .{ .text = text, .style = row_style });
|
try lines.append(arena, .{ .text = text, .style = row_style });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue