1436 lines
65 KiB
Zig
1436 lines
65 KiB
Zig
//! View models for portfolio sections (Positions, Options, CDs).
|
|
//! Produces renderer-agnostic display data consumed by both CLI and TUI.
|
|
//! Column widths, format strings, computed values, and style decisions
|
|
//! are defined here. Renderers are thin adapters that map StyleIntent
|
|
//! to platform-specific styles and emit pre-formatted text.
|
|
|
|
const std = @import("std");
|
|
const Lot = @import("../models/portfolio.zig").Lot;
|
|
const Date = @import("../Date.zig");
|
|
const fmt = @import("../format.zig");
|
|
const Money = @import("../Money.zig");
|
|
const Allocation = @import("../analytics/valuation.zig").Allocation;
|
|
|
|
// ── Positions (main holdings table) ───────────────────────────
|
|
|
|
/// Fixed knobs for the main holdings table. The numeric column widths
|
|
/// are NOT fixed any more: they're computed per render by
|
|
/// `computeWidths` so the table fits its content (each column's header
|
|
/// label is the floor; the widest rendered datum is the ceiling). This
|
|
/// struct holds only the parts that never vary:
|
|
///
|
|
/// - The two columns that never need to grow: Weight (bounded to
|
|
/// "100.0%") and Date (always "YYYY-MM-DD" plus a one-col ST/LT
|
|
/// indicator).
|
|
/// - The minimum width of each dynamic column, which equals the
|
|
/// width of that column's header label so the label is never
|
|
/// truncated (even for an empty portfolio).
|
|
/// - The fixed weight/date format specs and the header labels.
|
|
///
|
|
/// `PositionsWidths` (via `computeWidths`) is the single source of
|
|
/// truth for the dynamic widths. It's consumed by the CLI
|
|
/// (commands/portfolio.zig), the TUI rows + header
|
|
/// (tui/portfolio_tab.zig), and the TUI click-to-sort hit-test offsets,
|
|
/// so they all size identically and can't drift apart.
|
|
pub const PositionsLayout = struct {
|
|
const cp = std.fmt.comptimePrint;
|
|
|
|
// Fixed columns (never grow). The 1-col separating space between
|
|
// columns is added at layout time, not included here.
|
|
pub const weight_w = 8; // "NNN.N%"
|
|
pub const date_w = 13; // "YYYY-MM-DD" + " " + ST/LT indicator
|
|
pub const account_w = 8; // separator rule only; header/data are natural width
|
|
|
|
// Minimum width of each dynamic column = its header label width, so
|
|
// the label never truncates. (min_symbol_w also covers the lot-row
|
|
// status text "closed".)
|
|
pub const min_symbol_w = "Symbol".len; // 6
|
|
pub const min_shares_w = "Shares".len; // 6
|
|
pub const min_price_w = "Avg Cost".len; // 8 (wider of "Avg Cost" / "Price")
|
|
pub const min_value_w = "Market Value".len; // 12
|
|
pub const min_gainloss_w = "Gain/Loss".len; // 9
|
|
|
|
// Fixed-column format specs (weight + date never vary). `weight_num`
|
|
// renders a float in place (data rows); `weight_str` right-justifies
|
|
// a pre-formatted string (headers, the TUI's pre-rendered cells);
|
|
// `date_str` left-justifies the date text.
|
|
pub const weight_num = cp("{{d:>{d}.1}}%", .{weight_w - 1});
|
|
pub const weight_str = cp("{{s:>{d}}}", .{weight_w});
|
|
pub const date_str = cp("{{s:<{d}}}", .{date_w});
|
|
|
|
pub const header_labels = .{ "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account" };
|
|
};
|
|
|
|
/// Per-render widths for the holdings table's dynamic columns, plus the
|
|
/// two fixed ones mirrored in so callers have one place that answers
|
|
/// "how wide is column X". Field defaults are the per-column minimums,
|
|
/// so a default-initialized value (e.g. a TUI state's cached widths
|
|
/// before the first render) still yields a valid, non-truncating
|
|
/// layout. Populate via `computeWidths`.
|
|
pub const PositionsWidths = struct {
|
|
symbol_w: usize = PositionsLayout.min_symbol_w,
|
|
shares_w: usize = PositionsLayout.min_shares_w,
|
|
price_w: usize = PositionsLayout.min_price_w,
|
|
value_w: usize = PositionsLayout.min_value_w,
|
|
gainloss_w: usize = PositionsLayout.min_gainloss_w,
|
|
weight_w: usize = PositionsLayout.weight_w,
|
|
date_w: usize = PositionsLayout.date_w,
|
|
};
|
|
|
|
/// Compute the holdings-table column widths from the data that will be
|
|
/// rendered: every allocation row, every stock lot row (lot
|
|
/// `open_price` lands in the Avg Cost column, and a single lot's
|
|
/// gain/loss can exceed the netted position/total gain/loss when lots
|
|
/// have opposite signs, so both must be observed), the TOTAL row, and
|
|
/// the watchlist rows (which share the Symbol / Price columns). Each
|
|
/// width starts at its header-label minimum and grows to fit.
|
|
///
|
|
/// `watch_prices` is keyed by symbol; absent prices simply don't widen
|
|
/// the Price column. Pass an empty `watch_syms` to exclude the
|
|
/// watchlist (the TUI hides it under an account filter).
|
|
///
|
|
/// Note: callers that show a filtered subset (the TUI account filter)
|
|
/// may pass the full, unfiltered allocations here. That only ever
|
|
/// over-estimates a column (a hidden wide row can't be under-padded),
|
|
/// so alignment stays correct - the filtered view is at worst slightly
|
|
/// roomier than strictly necessary.
|
|
pub fn computeWidths(
|
|
allocations: []const Allocation,
|
|
lots: []const Lot,
|
|
total_value: f64,
|
|
total_gl: f64,
|
|
watch_syms: []const []const u8,
|
|
watch_prices: ?std.StringHashMap(f64),
|
|
) PositionsWidths {
|
|
var w: PositionsWidths = .{};
|
|
|
|
for (allocations) |a| {
|
|
w.symbol_w = @max(w.symbol_w, fmt.displayCols(a.display_symbol));
|
|
w.shares_w = @max(w.shares_w, sharesCols(a.shares));
|
|
w.price_w = @max(w.price_w, moneyCols(a.avg_cost));
|
|
w.price_w = @max(w.price_w, moneyCols(a.current_price));
|
|
w.value_w = @max(w.value_w, moneyCols(a.market_value));
|
|
w.gainloss_w = @max(w.gainloss_w, gainLossCols(a.unrealized_gain_loss));
|
|
}
|
|
|
|
for (lots) |lot| {
|
|
if (lot.security_type != .stock) continue;
|
|
w.shares_w = @max(w.shares_w, sharesCols(lot.effectiveShares()));
|
|
w.price_w = @max(w.price_w, moneyCols(lot.effectiveOpenPrice()));
|
|
const use_price = effectivePriceFor(allocations, lot);
|
|
// A ratio'd lot renders its own effective price in the Price
|
|
// cell (see `hasOwnPrice`), and that price can exceed every
|
|
// allocation's raw price - an 8.6x institutional ratio puts a
|
|
// $90 base ticker at $775. Observe it or the cell overflows its
|
|
// column.
|
|
if (hasOwnPrice(lot)) w.price_w = @max(w.price_w, moneyCols(use_price));
|
|
w.value_w = @max(w.value_w, moneyCols(lot.effectiveShares() * use_price));
|
|
w.gainloss_w = @max(w.gainloss_w, gainLossCols(lot.effectiveShares() * (use_price - lot.effectiveOpenPrice())));
|
|
}
|
|
|
|
w.value_w = @max(w.value_w, moneyCols(total_value));
|
|
w.gainloss_w = @max(w.gainloss_w, gainLossCols(total_gl));
|
|
|
|
for (watch_syms) |sym| {
|
|
w.symbol_w = @max(w.symbol_w, fmt.displayCols(sym));
|
|
if (watch_prices) |wp| {
|
|
if (wp.get(sym)) |price| w.price_w = @max(w.price_w, moneyCols(price));
|
|
}
|
|
}
|
|
|
|
return w;
|
|
}
|
|
|
|
/// Display columns of the default Money rendering of `amount`
|
|
/// ("$1,234.56"). Money output is ASCII, so byte length == columns.
|
|
fn moneyCols(amount: f64) usize {
|
|
var buf: [48]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
// 48 bytes covers any realistic dollar amount; the fixed writer
|
|
// returns WriteFailed rather than overflowing, in which case we
|
|
// fall back to the buffer length (an over-estimate, never an
|
|
// under-pad).
|
|
Money.from(amount).format(&w) catch return buf.len;
|
|
return w.buffered().len;
|
|
}
|
|
|
|
/// Display columns of the shares rendering ("{d:.1}").
|
|
fn sharesCols(shares: f64) usize {
|
|
var buf: [48]u8 = undefined;
|
|
const s = std.fmt.bufPrint(&buf, "{d:.1}", .{shares}) catch return buf.len;
|
|
return s.len;
|
|
}
|
|
|
|
/// Display columns of a gain/loss cell: one sign char + the money
|
|
/// magnitude.
|
|
fn gainLossCols(amount: f64) usize {
|
|
return 1 + moneyCols(if (amount < 0) -amount else amount);
|
|
}
|
|
|
|
// ── Holdings-row cells ────────────────────────────────────────
|
|
|
|
/// The six variable-width cells of one holdings-table row, in COLUMN
|
|
/// ORDER - matching `PositionsLayout.header_labels`:
|
|
///
|
|
/// Symbol | Shares | Avg Cost | Price | Market Value | Gain/Loss
|
|
///
|
|
/// Used both for the raw (unpadded) values a renderer computes and for the
|
|
/// padded result of `padRowCells`.
|
|
///
|
|
/// The point of naming these is that the TUI used to hand-roll the same six
|
|
/// cells three times - once each for position, lot and watchlist rows - with
|
|
/// per-arm local names that drifted apart. The lot arm ended up calling its
|
|
/// Avg Cost value `lot_price_str` and its Price value `lot_eff_price_str`,
|
|
/// so "price" named a cost and the two arms disagreed about the same two
|
|
/// columns. Nothing caught it, because a TUI row needs a live `App` to
|
|
/// render and there is no harness for that. A struct with named fields
|
|
/// can't be transposed silently the way two similar locals can.
|
|
pub const RowCells = struct {
|
|
symbol: []const u8,
|
|
shares: []const u8,
|
|
/// Avg Cost column: per-share cost basis.
|
|
cost: []const u8,
|
|
/// Price column: current per-share price.
|
|
price: []const u8,
|
|
value: []const u8,
|
|
gainloss: []const u8,
|
|
};
|
|
|
|
/// Pad each cell of `raw` to this frame's column widths.
|
|
///
|
|
/// Symbol is left-justified; every numeric cell is right-justified, which
|
|
/// is the one place the columns differ in treatment. Padding is
|
|
/// DISPLAY-COLUMN aware (`padRightToCols` / `padLeftToCols`), so a
|
|
/// multibyte cell - the `—` no-data sentinel, or any glyph a caller
|
|
/// substitutes - occupies its true width instead of being under-padded by
|
|
/// two columns the way a byte-counting `{s:>N}` would.
|
|
///
|
|
/// Cells are allocated in `arena` rather than caller stack buffers: twelve
|
|
/// scratch buffers in one function was what forced the `_buf2` / `_str3`
|
|
/// name suffixes, and the arena is already per-frame.
|
|
///
|
|
/// A cell wider than its column is returned unchanged - over-wide is the
|
|
/// safe direction, since `computeWidths` sizes columns from the same data
|
|
/// and the row renderer truncates at terminal width anyway.
|
|
pub fn padRowCells(
|
|
arena: std.mem.Allocator,
|
|
w: PositionsWidths,
|
|
raw: RowCells,
|
|
) !RowCells {
|
|
return .{
|
|
.symbol = try padCell(arena, raw.symbol, w.symbol_w, .left),
|
|
.shares = try padCell(arena, raw.shares, w.shares_w, .right),
|
|
.cost = try padCell(arena, raw.cost, w.price_w, .right),
|
|
.price = try padCell(arena, raw.price, w.price_w, .right),
|
|
.value = try padCell(arena, raw.value, w.value_w, .right),
|
|
.gainloss = try padCell(arena, raw.gainloss, w.gainloss_w, .right),
|
|
};
|
|
}
|
|
|
|
const Justify = enum { left, right };
|
|
|
|
/// Pad one cell to `cols` display columns, allocating in `arena`.
|
|
fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify: Justify) ![]const u8 {
|
|
const have = fmt.displayCols(content);
|
|
if (have >= cols) return content;
|
|
// Worst case is all-single-column content, so `cols - have` bytes of
|
|
// padding is always enough; multibyte content needs less.
|
|
const buf = try arena.alloc(u8, content.len + (cols - have));
|
|
switch (justify) {
|
|
// `padRightToCols` appends in place and requires its content to
|
|
// already sit at the start of the buffer; `padLeftToCols` copies.
|
|
.left => {
|
|
@memcpy(buf[0..content.len], content);
|
|
return fmt.padRightToCols(buf, buf[0..content.len], cols);
|
|
},
|
|
.right => return fmt.padLeftToCols(buf, content, cols),
|
|
}
|
|
}
|
|
|
|
/// The effective price of a single LOT: the base-ticker price from
|
|
/// `allocations` with this lot's `price_ratio` applied. The free-function
|
|
/// counterpart to `Lot.effectivePrice`, which takes the raw price as an
|
|
/// argument - this one resolves it from the allocations first. Returns 0
|
|
/// when no allocation matches the lot's `priceSymbol()` (an orphan lot),
|
|
/// which yields a trivially small cell that widens nothing and a
|
|
/// zero-value row rather than a crash.
|
|
///
|
|
/// EVERY per-lot display site MUST price through this instead of
|
|
/// reading `Allocation.current_price` directly.
|
|
/// `Allocation.current_price` is the RAW base-ticker price (see its doc
|
|
/// comment in `analytics/valuation.zig`), and `mergeAllocsBySymbol`
|
|
/// normalizes a merged group's `shares` into base-ticker-equivalent
|
|
/// units. A lot row that multiplies its own RAW shares by that RAW
|
|
/// price is therefore off by exactly the lot's ratio. That shipped: a
|
|
/// direct-indexing sleeve with `price_ratio:num:0.2387` rendered a
|
|
/// +$3.39M gain against a real +$433K, and the lot rows under a
|
|
/// position summed to three times the position's own market value.
|
|
///
|
|
/// Provenance follows the `is_preadjusted` rule from the pricing-model
|
|
/// block in `models/portfolio.zig`. The signal is `price_ratio`, and it
|
|
/// is answering ONE question: has this allocation been merged?
|
|
///
|
|
/// - `close_price` short-circuits first. It is the price the lot
|
|
/// actually closed at, already in the lot's own terms, so the ratio
|
|
/// is NOT reapplied. Matches how the contributions pipeline values
|
|
/// closed lots (`effectivePrice(close_price, true)`).
|
|
/// - `a.price_ratio != 1.0` means the allocation is UNMERGED and
|
|
/// carries this lot's own ratio (`positionsAsOf` groups by
|
|
/// `(priceSymbol, price_ratio)` and propagates the ratio through).
|
|
/// `portfolioSummary` set its `current_price` to
|
|
/// `pos.effectivePrice(raw, is_manual)` - the ratio is ALREADY
|
|
/// APPLIED - so applying it again would square it.
|
|
/// - `a.price_ratio == 1.0` means either a plain unratioed position
|
|
/// (apply 1.0, a no-op) or a MERGED group, where
|
|
/// `mergeAllocsBySymbol` normalized shares into base-ticker units,
|
|
/// set `current_price` to the raw base price, and reset the ratio
|
|
/// to 1.0. Both want the lot's own ratio applied.
|
|
///
|
|
/// Do NOT use `a.is_manual_price` for this. It looks like a provenance
|
|
/// flag and reads like the right answer, but it is orthogonal: a
|
|
/// manual `price::` is stored raw in the prices map and flagged, then
|
|
/// `portfolioSummary` folds it into `current_price` via
|
|
/// `effectivePrice(p, true)` - so by the time it reaches an
|
|
/// `Allocation` it has the same "already effective" shape as a live
|
|
/// unmerged price. Keying on it applied the ratio twice for every
|
|
/// unmerged, live-priced, ratio'd lot - i.e. the single-CIT-lot case
|
|
/// that `docs/reference/config/portfolio-srf.md` documents as the
|
|
/// primary use of `price_ratio`.
|
|
///
|
|
/// Known limit, deliberately not handled: a MERGED group that mixes
|
|
/// manual-priced and live-priced components gets a blended
|
|
/// `current_price` (`total_mv / norm_shares`) that is neither raw nor
|
|
/// preadjusted. That is an expressiveness gap in `mergeAllocsBySymbol`
|
|
/// itself, not something a lot-row accessor can repair. Merged groups
|
|
/// whose components share one provenance - the common case, and the
|
|
/// only case a ratio'd alias produces - are exact: with every
|
|
/// component live off the same raw price `r`, `total_mv / norm_shares`
|
|
/// reduces to `r`.
|
|
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot) f64 {
|
|
if (lot.close_price) |cp| return lot.effectivePrice(cp, true);
|
|
for (allocations) |a| {
|
|
if (!std.mem.eql(u8, a.symbol, lot.priceSymbol())) continue;
|
|
return lot.effectivePrice(a.current_price, a.price_ratio != 1.0);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/// Whether a lot row should render its own effective price in the Price
|
|
/// cell, which is otherwise blank because the position row above
|
|
/// already shows it.
|
|
///
|
|
/// True only for a ratio'd lot, where the lot's price genuinely differs
|
|
/// from its position's: the position row shows the base-ticker price
|
|
/// ($90.15 SPYM) while the lot trades at its institutional NAV ($775.17
|
|
/// at an 8.6x ratio). Showing it makes the ratio auditable at a glance
|
|
/// - and a blank cell is exactly why a 7-figure ratio bug in the
|
|
/// neighbouring Value and Gain/Loss cells went unnoticed.
|
|
///
|
|
/// Both the width pass and the two renderers gate on this, so they
|
|
/// cannot disagree about whether the cell is occupied.
|
|
pub fn hasOwnPrice(lot: Lot) bool {
|
|
return lot.price_ratio != 1.0;
|
|
}
|
|
|
|
/// Write `content` into a `width`-column field: right-justified (spaces
|
|
/// before) when `right`, else left-justified (spaces after).
|
|
/// Display-column aware. The streaming analogue of
|
|
/// `format.padLeftToCols` / `padRightToCols`, used by the CLI renderer
|
|
/// (which prints straight to the output writer) and by the header /
|
|
/// separator writers below.
|
|
pub fn writeCol(out: *std.Io.Writer, content: []const u8, width: usize, right: bool) std.Io.Writer.Error!void {
|
|
const cols = fmt.displayCols(content);
|
|
const pad = if (cols >= width) 0 else width - cols;
|
|
if (right) {
|
|
try out.splatByteAll(' ', pad);
|
|
try out.writeAll(content);
|
|
} else {
|
|
try out.writeAll(content);
|
|
try out.splatByteAll(' ', pad);
|
|
}
|
|
}
|
|
|
|
/// Write the CLI holdings-table column header (Symbol .. Account) sized
|
|
/// to `w`. Symbol and Date read left-to-right so their labels sit over
|
|
/// the start of the data; the numeric columns are right-justified.
|
|
pub fn writeHeader(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void {
|
|
try out.writeAll(" ");
|
|
try writeCol(out, "Symbol", w.symbol_w, false);
|
|
try out.writeByte(' ');
|
|
try writeCol(out, "Shares", w.shares_w, true);
|
|
try out.writeByte(' ');
|
|
try writeCol(out, "Avg Cost", w.price_w, true);
|
|
try out.writeByte(' ');
|
|
try writeCol(out, "Price", w.price_w, true);
|
|
try out.writeByte(' ');
|
|
try writeCol(out, "Market Value", w.value_w, true);
|
|
try out.writeByte(' ');
|
|
try writeCol(out, "Gain/Loss", w.gainloss_w, true);
|
|
try out.writeByte(' ');
|
|
try writeCol(out, "Weight", w.weight_w, true);
|
|
try out.writeByte(' ');
|
|
try writeCol(out, "Date", w.date_w, false);
|
|
try out.writeAll(" Account\n");
|
|
}
|
|
|
|
/// Write the CLI dashed separator under the header (9 column rules,
|
|
/// matching `writeHeader`).
|
|
pub fn writeSeparator(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void {
|
|
try out.writeAll(" ");
|
|
try writeRule(out, w.symbol_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.shares_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.price_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.price_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.value_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.gainloss_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.weight_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.date_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, PositionsLayout.account_w);
|
|
try out.writeByte('\n');
|
|
}
|
|
|
|
/// Write the CLI TOTAL-row sum rule, spanning Symbol .. Weight
|
|
/// (7 column rules).
|
|
pub fn writeTotalSeparator(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void {
|
|
try out.writeAll(" ");
|
|
try writeRule(out, w.symbol_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.shares_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.price_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.price_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.value_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.gainloss_w);
|
|
try out.writeByte(' ');
|
|
try writeRule(out, w.weight_w);
|
|
try out.writeByte('\n');
|
|
}
|
|
|
|
/// Write a dashed rule `width` columns wide.
|
|
fn writeRule(out: *std.Io.Writer, width: usize) std.Io.Writer.Error!void {
|
|
try out.splatByteAll('-', width);
|
|
}
|
|
|
|
// ── Options ───────────────────────────────────────────────────
|
|
|
|
/// Column layout for the Options section.
|
|
/// All format strings are derived from the width constants.
|
|
pub const OptionsLayout = struct {
|
|
const cp = std.fmt.comptimePrint;
|
|
pub const prefix = " ";
|
|
pub const symbol_w = 30;
|
|
pub const qty_w = 6;
|
|
pub const cost_w = 12;
|
|
pub const premium_w = 14;
|
|
pub const account_w = 10;
|
|
|
|
pub const premium_col_start: usize = prefix.len + symbol_w + 1 + qty_w + 1 + cost_w + 1;
|
|
|
|
pub const header = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:>{d}}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}";
|
|
pub const header_labels = .{ "Contract", "Qty", "Cost/Ctrct", "Premium", "Account" };
|
|
|
|
pub const separator = prefix ++ cp("{{s:->{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:->{d}}}", .{qty_w}) ++ " " ++ cp("{{s:->{d}}}", .{cost_w}) ++ " " ++ cp("{{s:->{d}}}", .{premium_w}) ++ " " ++ cp("{{s:->{d}}}", .{account_w});
|
|
pub const separator_fills = .{ "", "", "", "", "" };
|
|
|
|
pub const data_row = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{d:>{d}.0}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}";
|
|
};
|
|
|
|
/// A styled text span for multi-style row rendering.
|
|
pub const StyledSpan = struct {
|
|
text: []const u8,
|
|
style: fmt.StyleIntent,
|
|
};
|
|
|
|
/// A single option row with pre-computed display values.
|
|
pub const Option = struct {
|
|
lot: Lot,
|
|
premium: f64,
|
|
received: bool,
|
|
is_expired: bool,
|
|
row_style: fmt.StyleIntent,
|
|
premium_style: fmt.StyleIntent,
|
|
columns: [2]StyledSpan,
|
|
premium_col_start: usize,
|
|
};
|
|
|
|
/// Collection of prepared option rows. Owns all allocated text.
|
|
///
|
|
/// Rows are sorted by maturity ascending (then symbol), so expired
|
|
/// rows form a contiguous prefix: `items[0..expired_count]` are the
|
|
/// expired contracts and `items[expired_count..]` are the active ones
|
|
/// (including null-maturity lots, which sort last and are never
|
|
/// expired). Use `expiredItems()` / `activeItems()` rather than
|
|
/// re-deriving the split.
|
|
pub const Options = struct {
|
|
items: []const Option,
|
|
/// Count of leading expired rows (the contiguous prefix of
|
|
/// `items` whose `is_expired` is true).
|
|
expired_count: usize,
|
|
allocator: std.mem.Allocator,
|
|
|
|
/// Expired option rows (matured strictly before `as_of`).
|
|
pub fn expiredItems(self: Options) []const Option {
|
|
return self.items[0..self.expired_count];
|
|
}
|
|
|
|
/// Active option rows (not yet expired, including null-maturity).
|
|
pub fn activeItems(self: Options) []const Option {
|
|
return self.items[self.expired_count..];
|
|
}
|
|
|
|
/// Build sorted, filtered, display-ready option rows from raw lots.
|
|
pub fn init(as_of: Date, allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !Options {
|
|
var list: std.ArrayList(Option) = .empty;
|
|
errdefer {
|
|
for (list.items) |opt| allocator.free(opt.columns[0].text);
|
|
list.deinit(allocator);
|
|
}
|
|
|
|
var tmp: std.ArrayList(Lot) = .empty;
|
|
defer tmp.deinit(allocator);
|
|
for (lots) |lot| {
|
|
if (lot.security_type != .option) continue;
|
|
if (account_filter) |af| {
|
|
const la = lot.account orelse "";
|
|
if (!std.mem.eql(u8, la, af)) continue;
|
|
}
|
|
try tmp.append(allocator, lot);
|
|
}
|
|
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn);
|
|
|
|
var expired_count: usize = 0;
|
|
for (tmp.items) |lot| {
|
|
const qty = lot.shares;
|
|
const cost_per = lot.open_price;
|
|
const premium = @abs(qty) * cost_per * lot.multiplier;
|
|
const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
|
|
if (is_expired) expired_count += 1;
|
|
const received = qty < 0;
|
|
|
|
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
|
|
const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative;
|
|
|
|
var cost_buf: [24]u8 = undefined;
|
|
var prem_val_buf: [24]u8 = undefined;
|
|
const prem_money = std.fmt.bufPrint(&prem_val_buf, "{f}", .{Money.from(premium)}) catch "$?";
|
|
var prem_buf: [20]u8 = undefined;
|
|
const prem_str = if (received)
|
|
std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
|
|
else
|
|
std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
|
|
const acct = lot.account orelse "";
|
|
|
|
const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{
|
|
lot.displaySymbol(),
|
|
qty,
|
|
std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(cost_per)}) catch "$?",
|
|
prem_str,
|
|
acct,
|
|
});
|
|
|
|
try list.append(allocator, .{
|
|
.lot = lot,
|
|
.premium = premium,
|
|
.received = received,
|
|
.is_expired = is_expired,
|
|
.row_style = row_style,
|
|
.premium_style = premium_style,
|
|
.columns = .{
|
|
.{ .text = text, .style = row_style },
|
|
.{ .text = prem_str, .style = premium_style },
|
|
},
|
|
.premium_col_start = OptionsLayout.premium_col_start,
|
|
});
|
|
}
|
|
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
|
|
}
|
|
|
|
pub fn deinit(self: *Options) void {
|
|
for (self.items) |opt| self.allocator.free(opt.columns[0].text);
|
|
self.allocator.free(self.items);
|
|
self.items = &.{};
|
|
}
|
|
};
|
|
|
|
// ── CDs ───────────────────────────────────────────────────────
|
|
|
|
/// Column layout for the Certificates of Deposit section.
|
|
pub const CDsLayout = struct {
|
|
const cp = std.fmt.comptimePrint;
|
|
pub const prefix = " ";
|
|
pub const cusip_w = 12;
|
|
pub const face_w = 14;
|
|
pub const rate_w = 7;
|
|
pub const maturity_w = 10;
|
|
pub const desc_w = 40;
|
|
pub const account_w = 10;
|
|
|
|
pub const header = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ " {s} {s}";
|
|
pub const header_labels = .{ "CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account" };
|
|
|
|
pub const separator = prefix ++ cp("{{s:->{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:->{d}}}", .{face_w}) ++ " " ++ cp("{{s:->{d}}}", .{rate_w}) ++ " " ++ cp("{{s:->{d}}}", .{maturity_w}) ++ " " ++ cp("{{s:->{d}}}", .{desc_w}) ++ " " ++ cp("{{s:->{d}}}", .{account_w});
|
|
pub const separator_fills = .{ "", "", "", "", "", "" };
|
|
|
|
pub const data_row = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ " {s} {s}";
|
|
};
|
|
|
|
/// A single CD row with pre-computed display values.
|
|
pub const CD = struct {
|
|
lot: Lot,
|
|
is_expired: bool,
|
|
row_style: fmt.StyleIntent,
|
|
text: []const u8,
|
|
};
|
|
|
|
/// Collection of prepared CD rows. Owns all allocated text.
|
|
///
|
|
/// Rows are sorted by maturity ascending, so matured rows form a
|
|
/// contiguous prefix: `items[0..expired_count]` are matured and
|
|
/// `items[expired_count..]` are still-active (including null-maturity
|
|
/// lots, which sort last and are never expired). Use `expiredItems()`
|
|
/// / `activeItems()` rather than re-deriving the split.
|
|
pub const CDs = struct {
|
|
items: []const CD,
|
|
/// Count of leading matured rows (the contiguous prefix of
|
|
/// `items` whose `is_expired` is true).
|
|
expired_count: usize,
|
|
allocator: std.mem.Allocator,
|
|
|
|
/// Matured CD rows (maturity strictly before `as_of`).
|
|
pub fn expiredItems(self: CDs) []const CD {
|
|
return self.items[0..self.expired_count];
|
|
}
|
|
|
|
/// Active CD rows (not yet matured, including null-maturity).
|
|
pub fn activeItems(self: CDs) []const CD {
|
|
return self.items[self.expired_count..];
|
|
}
|
|
|
|
/// Build sorted, filtered, display-ready CD rows from raw lots.
|
|
pub fn init(as_of: Date, allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !CDs {
|
|
var list: std.ArrayList(CD) = .empty;
|
|
errdefer {
|
|
for (list.items) |cd| allocator.free(cd.text);
|
|
list.deinit(allocator);
|
|
}
|
|
|
|
var tmp: std.ArrayList(Lot) = .empty;
|
|
defer tmp.deinit(allocator);
|
|
for (lots) |lot| {
|
|
if (lot.security_type != .cd) continue;
|
|
if (account_filter) |af| {
|
|
const la = lot.account orelse "";
|
|
if (!std.mem.eql(u8, la, af)) continue;
|
|
}
|
|
try tmp.append(allocator, lot);
|
|
}
|
|
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn);
|
|
|
|
var expired_count: usize = 0;
|
|
for (tmp.items) |lot| {
|
|
const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
|
|
if (is_expired) expired_count += 1;
|
|
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
|
|
|
|
var face_buf: [24]u8 = undefined;
|
|
var mat_buf: [10]u8 = undefined;
|
|
const mat_str: []const u8 = if (lot.maturity_date) |md| (std.fmt.bufPrint(&mat_buf, "{f}", .{md}) catch "????-??-??") else "--";
|
|
var rate_buf: [10]u8 = undefined;
|
|
const rate_str: []const u8 = if (lot.rate) |r|
|
|
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
|
|
else
|
|
"--";
|
|
const note_str: []const u8 = lot.note orelse "";
|
|
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
|
|
const acct = lot.account orelse "";
|
|
|
|
const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{
|
|
lot.displaySymbol(),
|
|
std.fmt.bufPrint(&face_buf, "{f}", .{Money.from(lot.shares)}) catch "$?",
|
|
rate_str,
|
|
mat_str,
|
|
note_display,
|
|
acct,
|
|
});
|
|
|
|
try list.append(allocator, .{
|
|
.lot = lot,
|
|
.is_expired = is_expired,
|
|
.row_style = row_style,
|
|
.text = text,
|
|
});
|
|
}
|
|
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
|
|
}
|
|
|
|
pub fn deinit(self: *CDs) void {
|
|
for (self.items) |cd| self.allocator.free(cd.text);
|
|
self.allocator.free(self.items);
|
|
self.items = &.{};
|
|
}
|
|
};
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────
|
|
|
|
const testing = std.testing;
|
|
// Test-only, and the module rather than a type extraction: the
|
|
// lot-row/position-row reconciliation invariant has to go through the
|
|
// real `positionsAsOf` -> `portfolioSummary` -> `mergeAllocsBySymbol`
|
|
// pipeline, because the bug lived in the seam between them.
|
|
const portfolio_mod = @import("../models/portfolio.zig");
|
|
const valuation = @import("../analytics/valuation.zig");
|
|
|
|
test "Options.init: expired rows form a prefix; active/expired slices split correctly" {
|
|
const as_of = Date.fromYmd(2024, 6, 1);
|
|
const lots = [_]Lot{
|
|
// active (future maturity)
|
|
.{ .symbol = "AAA 2024-12-01 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1) },
|
|
// expired (past maturity)
|
|
.{ .symbol = "BBB 2024-01-01 C50", .shares = 1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 1.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 1, 1) },
|
|
// null-maturity option: never expired, sorts last
|
|
.{ .symbol = "CCC", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.0, .security_type = .option },
|
|
// non-option: ignored
|
|
.{ .symbol = "ZZZ", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .stock },
|
|
};
|
|
var opts = try Options.init(as_of, testing.allocator, &lots, null);
|
|
defer opts.deinit();
|
|
|
|
try testing.expectEqual(@as(usize, 3), opts.items.len);
|
|
try testing.expectEqual(@as(usize, 1), opts.expired_count);
|
|
try testing.expectEqual(@as(usize, 1), opts.expiredItems().len);
|
|
try testing.expectEqual(@as(usize, 2), opts.activeItems().len);
|
|
try testing.expect(opts.expiredItems()[0].is_expired);
|
|
try testing.expectEqualStrings("BBB 2024-01-01 C50", opts.expiredItems()[0].lot.symbol);
|
|
for (opts.activeItems()) |a| try testing.expect(!a.is_expired);
|
|
}
|
|
|
|
test "Options.init: no expired items yields empty expired slice" {
|
|
const as_of = Date.fromYmd(2024, 6, 1);
|
|
const lots = [_]Lot{
|
|
.{ .symbol = "AAA", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1) },
|
|
};
|
|
var opts = try Options.init(as_of, testing.allocator, &lots, null);
|
|
defer opts.deinit();
|
|
try testing.expectEqual(@as(usize, 0), opts.expired_count);
|
|
try testing.expectEqual(@as(usize, 0), opts.expiredItems().len);
|
|
try testing.expectEqual(@as(usize, 1), opts.activeItems().len);
|
|
}
|
|
|
|
test "CDs.init: matured rows form a prefix; active/expired slices split correctly" {
|
|
const as_of = Date.fromYmd(2024, 6, 1);
|
|
const lots = [_]Lot{
|
|
.{ .symbol = "CD-ACTIVE", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 1, 1), .rate = 4.5 },
|
|
.{ .symbol = "CD-MATURED", .shares = 5000, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 1, 1), .rate = 3.0 },
|
|
};
|
|
var cds = try CDs.init(as_of, testing.allocator, &lots, null);
|
|
defer cds.deinit();
|
|
|
|
try testing.expectEqual(@as(usize, 2), cds.items.len);
|
|
try testing.expectEqual(@as(usize, 1), cds.expired_count);
|
|
try testing.expectEqual(@as(usize, 1), cds.expiredItems().len);
|
|
try testing.expectEqual(@as(usize, 1), cds.activeItems().len);
|
|
try testing.expect(cds.expiredItems()[0].is_expired);
|
|
try testing.expect(!cds.activeItems()[0].is_expired);
|
|
try testing.expectEqualStrings("CD-MATURED", cds.expiredItems()[0].lot.symbol);
|
|
try testing.expectEqualStrings("CD-ACTIVE", cds.activeItems()[0].lot.symbol);
|
|
}
|
|
|
|
test "CDs.init: all matured yields empty active slice" {
|
|
const as_of = Date.fromYmd(2024, 6, 1);
|
|
const lots = [_]Lot{
|
|
.{ .symbol = "CD-OLD-1", .shares = 5000, .open_date = Date.fromYmd(2022, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2023, 1, 1) },
|
|
.{ .symbol = "CD-OLD-2", .shares = 7000, .open_date = Date.fromYmd(2022, 6, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 1, 1) },
|
|
};
|
|
var cds = try CDs.init(as_of, testing.allocator, &lots, null);
|
|
defer cds.deinit();
|
|
try testing.expectEqual(@as(usize, 2), cds.expired_count);
|
|
try testing.expectEqual(@as(usize, 2), cds.expiredItems().len);
|
|
try testing.expectEqual(@as(usize, 0), cds.activeItems().len);
|
|
}
|
|
|
|
// ── effectivePriceFor / hasOwnPrice ─────────────────────────────
|
|
|
|
// ── padRowCells ───────────────────────────────────────────────
|
|
|
|
/// Widths with every column distinct, so a test can tell which column a
|
|
/// cell landed in purely from how wide it came back.
|
|
fn rcWidths() PositionsWidths {
|
|
return .{
|
|
.symbol_w = 10,
|
|
.shares_w = 9,
|
|
.price_w = 8,
|
|
.value_w = 14,
|
|
.gainloss_w = 12,
|
|
};
|
|
}
|
|
|
|
test "padRowCells: every cell lands in its own column at its own width" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const a = arena_state.allocator();
|
|
const w = rcWidths();
|
|
|
|
const c = try padRowCells(a, w, .{
|
|
.symbol = "AAPL",
|
|
.shares = "10.0",
|
|
.cost = "$150.00",
|
|
.price = "$175.00",
|
|
.value = "$1,750.00",
|
|
.gainloss = "+$250.00",
|
|
});
|
|
|
|
try testing.expectEqual(@as(usize, w.symbol_w), fmt.displayCols(c.symbol));
|
|
try testing.expectEqual(@as(usize, w.shares_w), fmt.displayCols(c.shares));
|
|
try testing.expectEqual(@as(usize, w.price_w), fmt.displayCols(c.cost));
|
|
try testing.expectEqual(@as(usize, w.price_w), fmt.displayCols(c.price));
|
|
try testing.expectEqual(@as(usize, w.value_w), fmt.displayCols(c.value));
|
|
try testing.expectEqual(@as(usize, w.gainloss_w), fmt.displayCols(c.gainloss));
|
|
|
|
// Symbol is the one left-justified column; the numerics are right.
|
|
try testing.expect(std.mem.startsWith(u8, c.symbol, "AAPL"));
|
|
try testing.expect(std.mem.endsWith(u8, c.shares, "10.0"));
|
|
try testing.expect(std.mem.endsWith(u8, c.cost, "$150.00"));
|
|
try testing.expect(std.mem.endsWith(u8, c.price, "$175.00"));
|
|
try testing.expect(std.mem.endsWith(u8, c.value, "$1,750.00"));
|
|
try testing.expect(std.mem.endsWith(u8, c.gainloss, "+$250.00"));
|
|
}
|
|
|
|
test "padRowCells: cost and price do not clobber each other" {
|
|
// These two share `price_w`. Three hand-rolled copies of this layout
|
|
// used to pad them into separate stack buffers; a shared or reused
|
|
// buffer would silently make the two columns equal, and no TUI test
|
|
// exists to notice. Pin it.
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const a = arena_state.allocator();
|
|
|
|
const c = try padRowCells(a, rcWidths(), .{
|
|
.symbol = "X",
|
|
.shares = "1",
|
|
.cost = "$1.00",
|
|
.price = "$9.00",
|
|
.value = "$9.00",
|
|
.gainloss = "+$8.00",
|
|
});
|
|
try testing.expect(std.mem.endsWith(u8, c.cost, "$1.00"));
|
|
try testing.expect(std.mem.endsWith(u8, c.price, "$9.00"));
|
|
try testing.expect(!std.mem.eql(u8, c.cost, c.price));
|
|
// Distinct backing memory, not two views of one buffer.
|
|
try testing.expect(c.cost.ptr != c.price.ptr);
|
|
}
|
|
|
|
test "padRowCells: the COST value stays in the cost column" {
|
|
// The regression this whole type exists for. The TUI lot row named its
|
|
// Avg Cost value `lot_price_str` and its Price value
|
|
// `lot_eff_price_str`, so "price" named a cost and the position and lot
|
|
// arms disagreed about the same two columns. Renaming toward consistency
|
|
// risked transposing them, and a TUI row needs a live `App` to render,
|
|
// so nothing would have caught it.
|
|
//
|
|
// Named fields make the mapping assertable: feed unmistakable values and
|
|
// check each comes back from the field it was handed to.
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const a = arena_state.allocator();
|
|
|
|
const c = try padRowCells(a, rcWidths(), .{
|
|
.symbol = "SYM",
|
|
.shares = "SHR",
|
|
.cost = "COST",
|
|
.price = "PRICE",
|
|
.value = "VALUE",
|
|
.gainloss = "GL",
|
|
});
|
|
try testing.expect(std.mem.indexOf(u8, c.cost, "COST") != null);
|
|
try testing.expect(std.mem.indexOf(u8, c.price, "PRICE") != null);
|
|
// ...and no leakage in either direction.
|
|
try testing.expect(std.mem.indexOf(u8, c.cost, "PRICE") == null);
|
|
try testing.expect(std.mem.indexOf(u8, c.price, "COST") == null);
|
|
try testing.expect(std.mem.indexOf(u8, c.value, "VALUE") != null);
|
|
try testing.expect(std.mem.indexOf(u8, c.gainloss, "GL") != null);
|
|
try testing.expect(std.mem.indexOf(u8, c.symbol, "SYM") != null);
|
|
try testing.expect(std.mem.indexOf(u8, c.shares, "SHR") != null);
|
|
}
|
|
|
|
test "padRowCells: a multibyte cell is padded by display columns" {
|
|
// The no-data sentinel is one display column in three bytes. Padding it
|
|
// by BYTES under-fills the cell by two columns and skews every column to
|
|
// its right - the exact defect that hit the compare table's sentinel
|
|
// rows. `padRowCells` must not reintroduce it.
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const a = arena_state.allocator();
|
|
const w = rcWidths();
|
|
|
|
const c = try padRowCells(a, w, .{
|
|
.symbol = "SYM",
|
|
.shares = "1",
|
|
.cost = fmt.no_data_sentinel,
|
|
.price = fmt.no_data_sentinel,
|
|
.value = "$1.00",
|
|
.gainloss = "+$0.00",
|
|
});
|
|
try testing.expectEqual(@as(usize, w.price_w), fmt.displayCols(c.cost));
|
|
try testing.expectEqual(@as(usize, w.price_w), fmt.displayCols(c.price));
|
|
// Byte length exceeds the column width precisely because the glyph is
|
|
// multibyte - proof the padding counted columns, not bytes.
|
|
try testing.expect(c.cost.len > w.price_w);
|
|
}
|
|
|
|
test "padRowCells: an empty cell fills its column" {
|
|
// A lot row leaves the Price cell blank unless the lot is ratio'd. It
|
|
// still has to occupy the column or the row shears.
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const a = arena_state.allocator();
|
|
const w = rcWidths();
|
|
|
|
const c = try padRowCells(a, w, .{
|
|
.symbol = "open",
|
|
.shares = "100.0",
|
|
.cost = "$97.50",
|
|
.price = "",
|
|
.value = "$9,750.00",
|
|
.gainloss = "+$0.00",
|
|
});
|
|
try testing.expectEqual(@as(usize, w.price_w), fmt.displayCols(c.price));
|
|
try testing.expectEqualStrings(" ", c.price);
|
|
}
|
|
|
|
test "padRowCells: an over-wide cell is returned unchanged" {
|
|
// Over-wide is the safe direction: `computeWidths` sizes columns from
|
|
// the same data, and the renderer truncates at terminal width. Silently
|
|
// clipping here would corrupt a figure instead of just crowding it.
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const a = arena_state.allocator();
|
|
|
|
const huge = "$123,456,789.00";
|
|
const c = try padRowCells(a, rcWidths(), .{
|
|
.symbol = "SYM",
|
|
.shares = "1",
|
|
.cost = huge,
|
|
.price = "$1.00",
|
|
.value = "$1.00",
|
|
.gainloss = "+$0.00",
|
|
});
|
|
try testing.expectEqualStrings(huge, c.cost);
|
|
}
|
|
|
|
test "effectivePriceFor: live price gets the lot's ratio applied" {
|
|
// The bug this guards: `Allocation.current_price` is the RAW
|
|
// base-ticker price. A proxied sleeve quoted off a $90.15 base at an
|
|
// 8.6x institutional ratio prices at $775.17, not $90.15.
|
|
const allocs = [_]Allocation{mkAlloc("BENCH", 6097.4, 53.65, 90.15, 549779, 222651)};
|
|
const lot = Lot{
|
|
.symbol = "DI-IDX",
|
|
.ticker = "BENCH",
|
|
.price_ratio = 8.598685594945021,
|
|
.shares = 709.235272,
|
|
.open_date = Date.fromYmd(2026, 2, 25),
|
|
.open_price = 461.240208,
|
|
};
|
|
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot), 1e-6);
|
|
}
|
|
|
|
test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
|
|
// The sign of the failure matters: an under-1.0 ratio is what
|
|
// produced the +$3.39M phantom gain, because skipping the ratio
|
|
// INFLATES the price. 5075.077 shares at a raw $765.91 is $3.89M;
|
|
// the real effective price is 765.91 * 0.2387 = $182.82 -> $927,824.
|
|
const allocs = [_]Allocation{mkAlloc("BENCH", 1211.4, 408.47, 765.91, 927824, 433004)};
|
|
const lot = Lot{
|
|
.symbol = "AGG-LC",
|
|
.ticker = "BENCH",
|
|
.price_ratio = 0.2386960690140873,
|
|
.shares = 5075.077,
|
|
.open_date = Date.fromYmd(2026, 2, 26),
|
|
.open_price = 97.50,
|
|
};
|
|
const eff_price = effectivePriceFor(&allocs, lot);
|
|
try testing.expectApproxEqRel(@as(f64, 182.8197), eff_price, 1e-6);
|
|
// The whole point: value must land on the real figure, not the
|
|
// ratio-skipped one.
|
|
try testing.expectApproxEqRel(@as(f64, 927824.086), lot.effectiveShares() * eff_price, 1e-6);
|
|
try testing.expect(lot.effectiveShares() * eff_price < 1_000_000);
|
|
}
|
|
|
|
test "effectivePriceFor: ratio 1.0 passes the raw price straight through" {
|
|
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
|
const lot = Lot{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
|
|
try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot));
|
|
}
|
|
|
|
test "effectivePriceFor: unmerged live price is already effective, ratio NOT reapplied" {
|
|
// THE REGRESSION. A single ratio'd lot whose ticker is shared with
|
|
// nobody produces an UNMERGED allocation: `positionsAsOf` groups by
|
|
// `(priceSymbol, price_ratio)`, so `Allocation.price_ratio` is the
|
|
// lot's own 5.0, and `portfolioSummary` already folded that ratio
|
|
// into `current_price`. Applying it again squares it.
|
|
//
|
|
// This is the documented primary use of `price_ratio` - a lone 401k
|
|
// CIT lot priced off its retail sibling (see
|
|
// docs/reference/config/portfolio-srf.md). Keying provenance on
|
|
// `is_manual_price` returned 144.04 * 5.0 = 720.20 here.
|
|
const allocs = [_]Allocation{mkAllocUnmerged("VTTHX", 1200, 106.99, 144.04, 5.0, 44_460)};
|
|
const lot = Lot{
|
|
.symbol = "02315N600",
|
|
.ticker = "VTTHX",
|
|
.price_ratio = 5.0,
|
|
.shares = 1200,
|
|
.open_date = Date.fromYmd(2026, 2, 26),
|
|
.open_price = 106.99,
|
|
};
|
|
try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot));
|
|
// And the lot row must reconcile with its own position row.
|
|
try testing.expectApproxEqRel(
|
|
allocs[0].market_value,
|
|
lot.effectiveShares() * effectivePriceFor(&allocs, lot),
|
|
1e-9,
|
|
);
|
|
}
|
|
|
|
test "effectivePriceFor: unmerged manual price is also already effective" {
|
|
// Same unmerged shape, manual `price::` instead of a candle close.
|
|
// `buildFallbackPrices` stores the raw override and flags the
|
|
// allocation, then `portfolioSummary` folds it in via
|
|
// `effectivePrice(p, true)` - so by the time it reaches an
|
|
// Allocation it has the same "already effective" shape as the live
|
|
// case above. `price_ratio != 1.0` covers both; `is_manual_price` is
|
|
// not consulted.
|
|
var alloc = mkAllocUnmerged("ORCX", 100, 18.15, 19.01, 5.0, 86);
|
|
alloc.is_manual_price = true;
|
|
const allocs = [_]Allocation{alloc};
|
|
const lot = Lot{
|
|
.symbol = "ORCX",
|
|
.price_ratio = 5.0,
|
|
.shares = 100,
|
|
.open_date = Date.fromYmd(2026, 2, 26),
|
|
.open_price = 18.15,
|
|
};
|
|
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot));
|
|
}
|
|
|
|
test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
|
|
// The counterpart, and why the discriminator can't just be "always
|
|
// skip". `mergeAllocsBySymbol` normalizes shares to base-ticker
|
|
// units, sets `current_price` to the raw base price, and resets
|
|
// `price_ratio` to 1.0 - so the lot's own ratio must be applied.
|
|
// `is_manual_price` is false in both this case and the unmerged live
|
|
// case above, which is exactly why it cannot discriminate them.
|
|
const merged = [_]Allocation{mkAlloc("BENCH", 1929.2, 426.05, 765.91, 1_477_603, 655_655)};
|
|
const lot = Lot{
|
|
.symbol = "DI-IDX",
|
|
.ticker = "BENCH",
|
|
.price_ratio = 1.0120921601549708,
|
|
.shares = 709.235272,
|
|
.open_date = Date.fromYmd(2026, 2, 25),
|
|
.open_price = 461.240208,
|
|
};
|
|
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot), 1e-6);
|
|
}
|
|
|
|
test "effectivePriceFor: close_price wins over the allocation and skips the ratio" {
|
|
// A closed lot's `close_price` is the price it actually closed at -
|
|
// already in the lot's own units. Matches how the contributions
|
|
// pipeline values closed lots (`effectivePrice(close_price, true)`).
|
|
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
|
|
const lot = Lot{
|
|
.symbol = "CUSIP1",
|
|
.ticker = "BENCH",
|
|
.price_ratio = 5.061982036579556,
|
|
.shares = 2412.601,
|
|
.open_date = Date.fromYmd(2026, 2, 26),
|
|
.open_price = 106.99,
|
|
.close_price = 150.39,
|
|
};
|
|
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot));
|
|
}
|
|
|
|
test "effectivePriceFor: orphan lot with no matching allocation yields 0" {
|
|
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
|
const lot = Lot{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7 };
|
|
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot));
|
|
}
|
|
|
|
test "effectivePriceFor: matches on priceSymbol, not the lot's own symbol" {
|
|
// A CUSIP lot must find its allocation under the ticker alias.
|
|
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
|
|
const by_cusip = Lot{ .symbol = "02315N600", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
|
|
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip));
|
|
var aliased = by_cusip;
|
|
aliased.ticker = "BENCH";
|
|
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased));
|
|
}
|
|
|
|
test "hasOwnPrice: only ratio'd lots occupy the Price cell" {
|
|
const plain = Lot{ .symbol = "ABC", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1 };
|
|
try testing.expect(!hasOwnPrice(plain));
|
|
var ratioed = plain;
|
|
ratioed.price_ratio = 8.6;
|
|
try testing.expect(hasOwnPrice(ratioed));
|
|
// Under 1.0 counts too - that direction is the one that inflated.
|
|
ratioed.price_ratio = 0.2387;
|
|
try testing.expect(hasOwnPrice(ratioed));
|
|
}
|
|
|
|
test "effectivePriceFor: lot rows sum to their merged position's market value" {
|
|
// The exact shape that shipped the bug. Two proxied sleeves alias
|
|
// one base ticker with DIFFERENT ratios, so `positionsAsOf` groups
|
|
// them into two Positions and `mergeAllocsBySymbol` collapses those
|
|
// into ONE Allocation whose `shares` are normalized into
|
|
// base-ticker-equivalent units and whose `current_price` is the RAW
|
|
// base-ticker price.
|
|
//
|
|
// A lot row that multiplied its own raw shares by that raw price
|
|
// rendered 5075.077 * $765.91 = $3.89M for a lot really worth
|
|
// $928K, and the two lot rows summed to three times the position
|
|
// row above them. This asserts they reconcile.
|
|
const base_price: f64 = 765.91;
|
|
var lots = [_]Lot{
|
|
.{
|
|
.symbol = "DI-IDX",
|
|
.ticker = "BENCH",
|
|
.price_ratio = 1.0120921601549708,
|
|
.shares = 709.235272,
|
|
.open_date = Date.fromYmd(2026, 2, 25),
|
|
.open_price = 461.240208,
|
|
.account = "Sample Trust",
|
|
},
|
|
.{
|
|
.symbol = "AGG-LC",
|
|
.ticker = "BENCH",
|
|
.price_ratio = 0.2386960690140873,
|
|
.shares = 5075.077,
|
|
.open_date = Date.fromYmd(2026, 2, 26),
|
|
.open_price = 97.50,
|
|
.account = "Sample 401(k)",
|
|
},
|
|
};
|
|
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = testing.allocator };
|
|
const as_of = Date.fromYmd(2026, 8, 26);
|
|
|
|
const positions = try pf.positionsAsOf(testing.allocator, as_of);
|
|
defer testing.allocator.free(positions);
|
|
// Same ticker, different ratios -> two Positions, not one.
|
|
try testing.expectEqual(@as(usize, 2), positions.len);
|
|
|
|
var prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("BENCH", base_price);
|
|
|
|
var summary = try valuation.portfolioSummary(as_of, testing.allocator, pf, positions, prices, null);
|
|
defer summary.deinit(testing.allocator);
|
|
|
|
// ...which merge back into one Allocation.
|
|
try testing.expectEqual(@as(usize, 1), summary.allocations.len);
|
|
const a = summary.allocations[0];
|
|
try testing.expectEqualStrings("BENCH", a.symbol);
|
|
// Merged rows normalize to base units, so the ratio is spent.
|
|
try testing.expectEqual(@as(f64, 1.0), a.price_ratio);
|
|
try testing.expectApproxEqRel(base_price, a.current_price, 1e-9);
|
|
|
|
var lot_mv_total: f64 = 0;
|
|
var lot_gl_total: f64 = 0;
|
|
for (lots) |lot| {
|
|
const eff_price = effectivePriceFor(summary.allocations, lot);
|
|
lot_mv_total += lot.effectiveShares() * eff_price;
|
|
lot_gl_total += lot.effectiveShares() * (eff_price - lot.effectiveOpenPrice());
|
|
}
|
|
try testing.expectApproxEqRel(a.market_value, lot_mv_total, 1e-9);
|
|
try testing.expectApproxEqRel(a.unrealized_gain_loss, lot_gl_total, 1e-9);
|
|
|
|
// And pin the magnitude, so a future regression that merely
|
|
// reconciles two equally-wrong numbers still fails.
|
|
try testing.expectApproxEqRel(@as(f64, 1_477_603.06), lot_mv_total, 1e-6);
|
|
try testing.expectApproxEqRel(@as(f64, 655_655.23), lot_gl_total, 1e-6);
|
|
}
|
|
|
|
test "effectivePriceFor: a LONE ratio'd lot's row reconciles with its position" {
|
|
// The unmerged half of the same invariant, driven through the real
|
|
// pipeline. One ratio'd lot, ticker shared with nobody, so
|
|
// `mergeAllocsBySymbol` leaves it alone: `price_ratio` stays 5.0 and
|
|
// `current_price` is already effective.
|
|
//
|
|
// Under the `is_manual_price` discriminator this test failed with
|
|
// lot_mv = shares * raw * ratio * ratio - the ratio squared, a 5x
|
|
// overstatement of a real position. The merged test above passed
|
|
// throughout, which is how the bug shipped.
|
|
const raw_price: f64 = 28.808; // retail sibling; institutional NAV = 144.04
|
|
const ratio: f64 = 5.0;
|
|
var lots = [_]Lot{.{
|
|
.symbol = "02315N600",
|
|
.ticker = "VTTHX",
|
|
.price_ratio = ratio,
|
|
.shares = 1200,
|
|
.open_date = Date.fromYmd(2026, 2, 26),
|
|
.open_price = 106.99,
|
|
.account = "Sample 401(k)",
|
|
}};
|
|
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = testing.allocator };
|
|
const as_of = Date.fromYmd(2026, 8, 26);
|
|
|
|
const positions = try pf.positionsAsOf(testing.allocator, as_of);
|
|
defer testing.allocator.free(positions);
|
|
try testing.expectEqual(@as(usize, 1), positions.len);
|
|
|
|
var prices = std.StringHashMap(f64).init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("VTTHX", raw_price);
|
|
|
|
var summary = try valuation.portfolioSummary(as_of, testing.allocator, pf, positions, prices, null);
|
|
defer summary.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 1), summary.allocations.len);
|
|
const a = summary.allocations[0];
|
|
// Unmerged: the allocation KEEPS the lot's ratio, and current_price
|
|
// is the effective (institutional) price, not the raw retail one.
|
|
try testing.expectEqual(ratio, a.price_ratio);
|
|
try testing.expectApproxEqRel(raw_price * ratio, a.current_price, 1e-9);
|
|
|
|
const eff_price = effectivePriceFor(summary.allocations, lots[0]);
|
|
try testing.expectApproxEqRel(raw_price * ratio, eff_price, 1e-9);
|
|
try testing.expectApproxEqRel(a.market_value, lots[0].effectiveShares() * eff_price, 1e-9);
|
|
// Magnitude pin: 1200 * 144.04 = $172,848, NOT 1200 * 720.20 = $864,240.
|
|
try testing.expectApproxEqRel(@as(f64, 172_848.0), lots[0].effectiveShares() * eff_price, 1e-9);
|
|
}
|
|
|
|
// ── computeWidths ─────────────────────────────────────────────
|
|
|
|
/// Build a minimal Allocation for the width and effective-price tests.
|
|
/// `cost_basis` is derived so `unrealized_gain_loss` is consistent (not
|
|
/// that computeWidths reads cost_basis, but it keeps the fixture
|
|
/// honest).
|
|
///
|
|
/// `price_ratio` defaults to 1.0, which is the MERGED shape (or a plain
|
|
/// unratioed position): `current_price` is the raw base-ticker price.
|
|
/// For the unmerged shape use `mkAllocUnmerged` - the distinction is
|
|
/// load-bearing, see `effectivePriceFor`.
|
|
fn mkAlloc(symbol: []const u8, shares: f64, avg_cost: f64, current_price: f64, market_value: f64, gl: f64) Allocation {
|
|
return .{
|
|
.symbol = symbol,
|
|
.display_symbol = symbol,
|
|
.shares = shares,
|
|
.avg_cost = avg_cost,
|
|
.current_price = current_price,
|
|
.market_value = market_value,
|
|
.cost_basis = market_value - gl,
|
|
.weight = 1.0,
|
|
.unrealized_gain_loss = gl,
|
|
.unrealized_return = 0,
|
|
};
|
|
}
|
|
|
|
/// An UNMERGED allocation: one ratio'd position that shares its ticker
|
|
/// with nobody, so `mergeAllocsBySymbol` never touched it. It keeps its
|
|
/// lot's `price_ratio`, and `portfolioSummary` already folded that ratio
|
|
/// into `current_price` (`pos.effectivePrice(raw, is_manual)`), so
|
|
/// `current_price` here is the EFFECTIVE price, not the raw base price.
|
|
///
|
|
/// This is the shape every fixture was missing: `mkAlloc` leaves
|
|
/// `price_ratio` at 1.0, which is indistinguishable from a merged group,
|
|
/// so a whole suite of tests can pass while the unmerged path squares
|
|
/// the ratio.
|
|
fn mkAllocUnmerged(symbol: []const u8, shares: f64, avg_cost: f64, effective_price: f64, price_ratio: f64, gl: f64) Allocation {
|
|
const mv = shares * effective_price;
|
|
return .{
|
|
.symbol = symbol,
|
|
.display_symbol = symbol,
|
|
.shares = shares,
|
|
.avg_cost = avg_cost,
|
|
.current_price = effective_price,
|
|
.market_value = mv,
|
|
.cost_basis = mv - gl,
|
|
.weight = 1.0,
|
|
.unrealized_gain_loss = gl,
|
|
.unrealized_return = 0,
|
|
.price_ratio = price_ratio,
|
|
};
|
|
}
|
|
|
|
test "computeWidths: a ratio'd lot's effective price widens the Price column" {
|
|
// A ratio'd lot renders its own effective price in the
|
|
// otherwise-blank Price cell, and at an 8.6x institutional ratio
|
|
// that price dwarfs every allocation figure: base ticker $90.15 (6
|
|
// cols), avg cost $53.65 (6), lot open price $461.24 (7) - but the
|
|
// effective price is $775.17 (7)... so push the ratio higher to make
|
|
// it the strict maximum and prove the width pass observes it.
|
|
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 53.65, 90.15, 9015, 1000)};
|
|
const lots = [_]Lot{.{
|
|
.symbol = "DI-IDX",
|
|
.ticker = "BENCH",
|
|
.price_ratio = 150.0, // effective = $13,522.50 -> "$13,522.50" = 10 cols
|
|
.shares = 10,
|
|
.open_date = Date.fromYmd(2026, 2, 25),
|
|
.open_price = 100.0,
|
|
}};
|
|
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null);
|
|
try testing.expectEqual(@as(usize, 10), w.price_w);
|
|
}
|
|
|
|
test "computeWidths: the Price cell is measured only when the lot occupies it" {
|
|
// Needs an effective price that is NOT already measured from the
|
|
// allocations, or the gate is unfalsifiable: for an open ratio-1.0
|
|
// lot the effective price IS `alloc.current_price`, which is always
|
|
// measured. A closed lot's comes from its own `close_price` instead,
|
|
// so it is visible to the lot pass alone.
|
|
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
|
var lots = [_]Lot{.{
|
|
.symbol = "ABC",
|
|
.shares = 10,
|
|
.open_date = Date.fromYmd(2026, 2, 25),
|
|
.open_price = 1.0,
|
|
.close_price = 99999.99, // "$99,999.99" = 10 cols
|
|
}};
|
|
|
|
// Ratio 1.0: cell stays blank, so the 10-col price must not leak in.
|
|
// $50.00 / $60.00 / $1.00 are all under the 8-col "Avg Cost" floor.
|
|
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
|
|
|
|
// Ratio'd: the cell is occupied, so the same price now widens it.
|
|
lots[0].price_ratio = 2.0;
|
|
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
|
|
}
|
|
|
|
test "computeWidths: empty portfolio sits at the header-label floors" {
|
|
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null);
|
|
try testing.expectEqual(@as(usize, PositionsLayout.min_symbol_w), w.symbol_w);
|
|
try testing.expectEqual(@as(usize, PositionsLayout.min_shares_w), w.shares_w);
|
|
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), w.price_w);
|
|
try testing.expectEqual(@as(usize, PositionsLayout.min_value_w), w.value_w);
|
|
try testing.expectEqual(@as(usize, PositionsLayout.min_gainloss_w), w.gainloss_w);
|
|
// Fixed columns are mirrored in unchanged.
|
|
try testing.expectEqual(@as(usize, PositionsLayout.weight_w), w.weight_w);
|
|
try testing.expectEqual(@as(usize, PositionsLayout.date_w), w.date_w);
|
|
}
|
|
|
|
test "computeWidths: a small portfolio stays at the floors (tightens, no waste)" {
|
|
// IBM(3) / 10.0(4) / $150.00(7) / $155.00(7) / $1,550.00(9) / +$50.00(7)
|
|
// are all narrower than their header labels, so every column stays
|
|
// at its minimum. This is the "don't waste space" half of
|
|
// fit-to-content.
|
|
const allocs = [_]Allocation{mkAlloc("IBM", 10, 150, 155, 1550, 50)};
|
|
const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null);
|
|
try testing.expectEqual(@as(usize, 6), w.symbol_w); // "Symbol"
|
|
try testing.expectEqual(@as(usize, 6), w.shares_w); // "Shares"
|
|
try testing.expectEqual(@as(usize, 8), w.price_w); // "Avg Cost"
|
|
try testing.expectEqual(@as(usize, 12), w.value_w); // "Market Value"
|
|
try testing.expectEqual(@as(usize, 9), w.gainloss_w); // "Gain/Loss"
|
|
}
|
|
|
|
test "computeWidths: large crypto-scale values grow each column" {
|
|
// DOGE-USD(8) / 10000.0(7) / $42,000.00(10) / $420,000,000.00(15)
|
|
// / +$5,000,000.00(14, sign + $5,000,000.00).
|
|
const allocs = [_]Allocation{mkAlloc("DOGE-USD", 10000, 41500, 42000, 420000000, 5000000)};
|
|
const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null);
|
|
try testing.expectEqual(@as(usize, 8), w.symbol_w);
|
|
try testing.expectEqual(@as(usize, 7), w.shares_w);
|
|
try testing.expectEqual(@as(usize, 10), w.price_w); // max("$41,500.00","$42,000.00") = 10
|
|
try testing.expectEqual(@as(usize, 15), w.value_w);
|
|
try testing.expectEqual(@as(usize, 14), w.gainloss_w);
|
|
}
|
|
|
|
test "computeWidths: a single lot's gain/loss can exceed the netted position" {
|
|
// Position nets to $0 gain/loss, but two opposite-sign lots each
|
|
// swing $9,900 ("+$9,900.00" = 10 cols). Without scanning lots the
|
|
// Gain/Loss column would size to the $0 net (the 9-col floor) and a
|
|
// lot row would overflow.
|
|
const allocs = [_]Allocation{mkAlloc("ABC", 200, 100, 100, 20000, 0)};
|
|
const lots = [_]Lot{
|
|
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0 },
|
|
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 199.0 },
|
|
};
|
|
// Allocations only: stays at the 9-col floor.
|
|
const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null);
|
|
try testing.expectEqual(@as(usize, 9), no_lots.gainloss_w);
|
|
// With lots: grows to fit the per-lot swing.
|
|
const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null);
|
|
try testing.expectEqual(@as(usize, 10), w.gainloss_w);
|
|
}
|
|
|
|
test "computeWidths: non-stock lots skipped, close_price honored, orphan lot tolerated" {
|
|
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
|
const lots = [_]Lot{
|
|
// Non-stock lot is skipped: its huge share count must NOT widen
|
|
// the Shares column.
|
|
.{ .symbol = "CASHX", .shares = 999999, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash },
|
|
// Closed stock lot: market value / gain-loss use close_price
|
|
// (1234), not the allocation's current price (60). gl =
|
|
// 100 * (1234 - 10) = 122400 -> "+$122,400.00" = 12 cols.
|
|
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_price = 1234.0 },
|
|
// Stock lot whose symbol has no matching allocation and no
|
|
// close_price: currentPriceFor falls back to 0 (exercises the
|
|
// not-found path), contributing nothing.
|
|
.{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7.0 },
|
|
};
|
|
const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null);
|
|
// Non-stock lot skipped -> Shares stays at the 6-col floor (the
|
|
// 999999.0 = 8-col share count was ignored).
|
|
try testing.expectEqual(@as(usize, 6), w.shares_w);
|
|
// close_price honored -> Gain/Loss grows to the closed lot's 12-col
|
|
// swing. (Had currentPriceFor been used instead, the swing would be
|
|
// 100*(60-10) = 5000 -> 10 cols, so 12 proves close_price won.)
|
|
try testing.expectEqual(@as(usize, 12), w.gainloss_w);
|
|
}
|
|
|
|
test "computeWidths: watchlist symbols and prices widen Symbol / Price" {
|
|
var wp = std.StringHashMap(f64).init(testing.allocator);
|
|
defer wp.deinit();
|
|
try wp.put("VERYLONGSYM", 1234.56); // "$1,234.56" = 9 cols
|
|
// "NOPRICE" has no map entry, so it widens Symbol but not Price
|
|
// (exercises the price-absent branch).
|
|
const watch = [_][]const u8{ "VERYLONGSYM", "NOPRICE" };
|
|
const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp);
|
|
try testing.expectEqual(@as(usize, 11), w.symbol_w);
|
|
try testing.expectEqual(@as(usize, 9), w.price_w);
|
|
}
|
|
|
|
test "writeHeader / writeSeparator render the same column count as the widths" {
|
|
var buf: [256]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const widths: PositionsWidths = .{ .symbol_w = 8, .shares_w = 7, .price_w = 10, .value_w = 15, .gainloss_w = 14 };
|
|
try writeHeader(&w, widths);
|
|
const header = w.buffered();
|
|
try testing.expect(std.mem.indexOf(u8, header, "Symbol") != null);
|
|
try testing.expect(std.mem.indexOf(u8, header, "Market Value") != null);
|
|
try testing.expect(std.mem.indexOf(u8, header, "Account") != null);
|
|
try testing.expect(std.mem.endsWith(u8, header, "\n"));
|
|
|
|
var sbuf: [256]u8 = undefined;
|
|
var sw: std.Io.Writer = .fixed(&sbuf);
|
|
try writeSeparator(&sw, widths);
|
|
const sep = sw.buffered();
|
|
// The separator's per-column rules match the header columns. Its one
|
|
// intentional difference is the trailing Account rule, which is a
|
|
// fixed decorative width (account_w) rather than the natural-width
|
|
// "Account" label, so the separator is exactly that much longer.
|
|
try testing.expectEqual(
|
|
fmt.displayCols(header[0 .. header.len - 1]) - "Account".len + PositionsLayout.account_w,
|
|
fmt.displayCols(sep[0 .. sep.len - 1]),
|
|
);
|
|
}
|
|
|
|
test "computeWidths: lot columns size to effective (split-adjusted) shares" {
|
|
// A pre-split lot enriched with a 10:1 factor renders 1000 shares,
|
|
// so the shares column must be sized for the effective count, not
|
|
// the raw 100.
|
|
var lots = [_]Lot{
|
|
.{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40, .split_factor = 10.0 },
|
|
};
|
|
const no_allocs: []const Allocation = &.{};
|
|
const no_watch: []const []const u8 = &.{};
|
|
const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
|
|
try testing.expectEqual(sharesCols(1000.0), w.shares_w);
|
|
try testing.expect(w.shares_w >= sharesCols(100.0));
|
|
}
|