zfin/src/format.zig

1603 lines
65 KiB
Zig

//! Shared formatting utilities used by both CLI and TUI.
//!
//! Number formatting (fmtIntCommas, etc.) and financial helpers
//! (capitalGainsIndicator, filterNearMoney). The braille sparkline
//! chart lives in `charts/braille.zig`.
const std = @import("std");
const Date = @import("Date.zig");
const Money = @import("Money.zig");
const Candle = @import("models/candle.zig").Candle;
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 ─────────────────────────────────────────
/// Width of the symbol column in portfolio view (CLI + TUI).
pub const sym_col_width = 7;
/// Comptime format spec for left-aligned symbol column, e.g. "{s:<7}".
pub const sym_col_spec = std.fmt.comptimePrint("{{s:<{d}}}", .{sym_col_width});
/// Width of the account name column in cash section (CLI + TUI).
pub const cash_acct_width = 30;
/// Format the cash section column header: " Account Balance Note"
pub fn fmtCashHeader(buf: []u8) []const u8 {
const w = cash_acct_width;
var pos: usize = 0;
@memcpy(buf[0..2], " ");
pos += 2;
const acct_label = "Account";
@memcpy(buf[pos..][0..acct_label.len], acct_label);
@memset(buf[pos + acct_label.len ..][0 .. w - acct_label.len], ' ');
pos += w;
buf[pos] = ' ';
pos += 1;
const bal_label = "Balance";
const bal_pad = if (bal_label.len < 14) 14 - bal_label.len else 0;
@memset(buf[pos..][0..bal_pad], ' ');
pos += bal_pad;
@memcpy(buf[pos..][0..bal_label.len], bal_label);
pos += bal_label.len;
@memcpy(buf[pos..][0..2], " ");
pos += 2;
const note_label = "Note";
@memcpy(buf[pos..][0..note_label.len], note_label);
pos += note_label.len;
return buf[0..pos];
}
/// Format a cash row: " account_name $1,234.56 some note"
/// Returns a slice of `buf`.
pub fn fmtCashRow(buf: []u8, account: []const u8, amount: f64, note: ?[]const u8) []const u8 {
var money_buf: [24]u8 = undefined;
const money = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(amount)}) catch "$?";
const w = cash_acct_width;
// " {name:<w} {money:>14} {note}"
const prefix = " ";
var pos: usize = 0;
@memcpy(buf[pos..][0..prefix.len], prefix);
pos += prefix.len;
const name_len = @min(account.len, w);
@memcpy(buf[pos..][0..name_len], account[0..name_len]);
if (name_len < w) @memset(buf[pos + name_len ..][0 .. w - name_len], ' ');
pos += w;
buf[pos] = ' ';
pos += 1;
// Right-align money in 14 chars
const money_pad = if (money.len < 14) 14 - money.len else 0;
@memset(buf[pos..][0..money_pad], ' ');
pos += money_pad;
@memcpy(buf[pos..][0..money.len], money);
pos += money.len;
// Append note if present
if (note) |n| {
if (n.len > 0) {
@memcpy(buf[pos..][0..2], " ");
pos += 2;
const note_len = @min(n.len, buf.len - pos);
@memcpy(buf[pos..][0..note_len], n[0..note_len]);
pos += note_len;
}
}
return buf[0..pos];
}
/// Format the cash total separator line.
pub fn fmtCashSep(buf: []u8) []const u8 {
const w = cash_acct_width;
var pos: usize = 0;
@memcpy(buf[0..2], " ");
pos += 2;
@memset(buf[pos..][0..w], '-');
pos += w;
buf[pos] = ' ';
pos += 1;
@memset(buf[pos..][0..14], '-');
pos += 14;
return buf[0..pos];
}
/// Format the cash total row.
pub fn fmtCashTotal(buf: []u8, total: f64) []const u8 {
var money_buf: [24]u8 = undefined;
const money = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(total)}) catch "$?";
const w = cash_acct_width;
var pos: usize = 0;
@memcpy(buf[0..2], " ");
pos += 2;
// Right-align "TOTAL" in w chars
const label = "TOTAL";
const label_pad = w - label.len;
@memset(buf[pos..][0..label_pad], ' ');
pos += label_pad;
@memcpy(buf[pos..][0..label.len], label);
pos += label.len;
buf[pos] = ' ';
pos += 1;
const money_pad = if (money.len < 14) 14 - money.len else 0;
@memset(buf[pos..][0..money_pad], ' ');
pos += money_pad;
@memcpy(buf[pos..][0..money.len], money);
pos += money.len;
return buf[0..pos];
}
/// Format the illiquid section column header: " Asset Value Note"
pub fn fmtIlliquidHeader(buf: []u8) []const u8 {
const w = cash_acct_width;
var pos: usize = 0;
@memcpy(buf[0..2], " ");
pos += 2;
const asset_label = "Asset";
@memcpy(buf[pos..][0..asset_label.len], asset_label);
@memset(buf[pos + asset_label.len ..][0 .. w - asset_label.len], ' ');
pos += w;
buf[pos] = ' ';
pos += 1;
const val_label = "Value";
const val_pad = if (val_label.len < 14) 14 - val_label.len else 0;
@memset(buf[pos..][0..val_pad], ' ');
pos += val_pad;
@memcpy(buf[pos..][0..val_label.len], val_label);
pos += val_label.len;
@memcpy(buf[pos..][0..2], " ");
pos += 2;
const note_label = "Note";
@memcpy(buf[pos..][0..note_label.len], note_label);
pos += note_label.len;
return buf[0..pos];
}
/// Format an illiquid asset row: " House $1,200,000.00 Primary residence"
/// Returns a slice of `buf`.
pub fn fmtIlliquidRow(buf: []u8, name: []const u8, value: f64, note: ?[]const u8) []const u8 {
return fmtCashRow(buf, name, value, note);
}
/// Format the illiquid total separator line.
pub const fmtIlliquidSep = fmtCashSep;
/// Format the illiquid total row.
pub const fmtIlliquidTotal = fmtCashTotal;
// ── Number formatters ────────────────────────────────────────
// Money formatters live in `src/Money.zig`. Use
// `Money.from(amount)` with `{f}` for the default `$1,234.56`
// rendering, or call `.whole()` / `.trim()` / `.signed()` /
// `.padRight(N)` for variants. See `Money.zig` for the API.
/// Format an integer with commas (e.g. 1234567 -> "1,234,567").
pub fn fmtIntCommas(buf: []u8, value: u64) []const u8 {
var tmp: [32]u8 = undefined;
var pos: usize = tmp.len;
var v = value;
var digit_count: usize = 0;
if (v == 0) {
pos -= 1;
tmp[pos] = '0';
} else {
while (v > 0) {
if (digit_count > 0 and digit_count % 3 == 0) {
pos -= 1;
tmp[pos] = ',';
}
pos -= 1;
tmp[pos] = '0' + @as(u8, @intCast(v % 10));
v /= 10;
digit_count += 1;
}
}
const len = tmp.len - pos;
if (len > buf.len) return "?";
@memcpy(buf[0..len], tmp[pos..]);
return buf[0..len];
}
/// Format an earlier timestamp as relative time measured against a
/// reference point ("just now", "5m ago", "2h ago", "3d ago").
///
/// Pure: takes two unix-epoch-seconds values - `before_s` (the earlier
/// event being aged) and `after_s` (the reference "now"). Caller
/// captures `after_s` via `std.Io.Timestamp.now(io, .real).toSeconds()`
/// once per frame/command and passes it in.
///
/// Returns `""` when `before_s == 0` (caller-convention for "unset").
/// Returns `"just now"` when `before_s > after_s` (clock skew or unset)
/// or when the delta is under a minute.
pub fn fmtTimeAgo(buf: []u8, before_s: i64, after_s: i64) []const u8 {
if (before_s == 0) return "";
const delta = after_s - before_s;
if (delta < 0) return "just now";
if (delta < 60) return "just now";
if (delta < std.time.s_per_hour) {
return std.fmt.bufPrint(buf, "{d}m ago", .{@as(u64, @intCast(@divFloor(delta, std.time.s_per_min)))}) catch "?";
}
if (delta < std.time.s_per_day) {
return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divFloor(delta, std.time.s_per_hour)))}) catch "?";
}
return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divFloor(delta, std.time.s_per_day)))}) catch "?";
}
/// Format large numbers with T/B/M suffixes (e.g. "1.5B", "45.6M").
pub fn fmtLargeNum(val: f64) [15]u8 {
var result: [15]u8 = @splat(' ');
// bufPrint can only fail with NoSpaceLeft, which is impossible
// here: a 15-byte buffer comfortably holds any "{d:.1}<X>" value
// with X in {T,B,M} or "{d:.0}" for under-million values.
if (val >= 1_000_000_000_000) {
_ = std.fmt.bufPrint(&result, "{d:.1}T", .{val / 1_000_000_000_000}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
} else if (val >= 1_000_000_000) {
_ = std.fmt.bufPrint(&result, "{d:.1}B", .{val / 1_000_000_000}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
} else if (val >= 1_000_000) {
_ = std.fmt.bufPrint(&result, "{d:.1}M", .{val / 1_000_000}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
} else {
_ = std.fmt.bufPrint(&result, "{d:.0}", .{val}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
}
return result;
}
// ── Display-width helpers ────────────────────────────────────
/// Count the number of terminal display columns occupied by a
/// UTF-8 string. ASCII bytes count as 1 column; multibyte UTF-8
/// sequences (em-dash `—`, chevrons `▶`/`▼`, etc.) also count
/// as 1 column. Continuation bytes count as 0.
///
/// This is a pragmatic helper for table-layout code, not a
/// full Unicode width database - it doesn't attempt to handle
/// East-Asian wide chars, combining marks, or zero-width
/// joiners. The strings that flow through table cells in this
/// codebase are short and use only single-column glyphs.
pub fn displayCols(s: []const u8) usize {
var cols: usize = 0;
var i: usize = 0;
while (i < s.len) {
const b = s[i];
if (b < 0x80) {
cols += 1;
i += 1;
} else if (b < 0xC0) {
// Continuation byte (shouldn't lead a sequence; skip)
i += 1;
} else if (b < 0xE0) {
cols += 1;
i += 2;
} else if (b < 0xF0) {
cols += 1;
i += 3;
} else {
cols += 1;
i += 4;
}
}
return cols;
}
/// Right-pad `content` to `target_cols` *display columns* by
/// appending spaces in-place. `content` must already live at
/// the start of `buf`; the function appends spaces directly
/// after it and returns the padded slice. If `content` is
/// already at or beyond `target_cols`, it's returned unchanged.
/// If `buf` is too small for the padded result, returns the
/// original content unmodified.
///
/// Use when you've written a cell into a stack buffer and need
/// to pad it to a fixed-column table layout. Display-column
/// aware (so multibyte content like `—` doesn't get under-padded
/// the way Zig's byte-padding `{s: <N}` does).
pub fn padRightToCols(buf: []u8, content: []const u8, target_cols: usize) []const u8 {
const have = displayCols(content);
if (have >= target_cols) return content;
const pad = target_cols - have;
if (content.len + pad > buf.len) return content;
@memset(buf[content.len .. content.len + pad], ' ');
return buf[0 .. content.len + pad];
}
/// Left-pad `content` to `target_cols` display columns by writing
/// spaces *before* the content. The buffer must be large enough
/// to fit `pad + content.len` bytes; if not, returns content
/// unchanged.
///
/// Useful for right-aligning numeric cells that may contain
/// multibyte sentinels like `—`. The byte-padding `{s:>N}` form
/// under-pads multibyte content (3 bytes for `—` = 1 display
/// column, so `{s:>8}` produces a 6-col-wide cell instead of 8).
///
/// `content` is COPIED into `buf` after the leading spaces, so
/// the caller can pass any slice (it doesn't have to live in
/// `buf`). Returns the slice covering the full padded result.
pub fn padLeftToCols(buf: []u8, content: []const u8, target_cols: usize) []const u8 {
const have = displayCols(content);
if (have >= target_cols) return content;
const pad = target_cols - have;
if (pad + content.len > buf.len) return content;
@memset(buf[0..pad], ' ');
@memcpy(buf[pad..][0..content.len], content);
return buf[0 .. pad + content.len];
}
/// Truncate `content` to at most `max_cols` display columns,
/// returning a borrowed sub-slice of the input. Display-column
/// aware: walks UTF-8 sequences so a multibyte glyph isn't sliced
/// mid-codepoint and so column accounting matches what the user
/// sees on screen.
///
/// When `content` already fits, returns it unchanged. When it
/// doesn't, returns the longest prefix that fits in `max_cols`
/// columns. No ellipsis or marker is appended - callers that
/// want one should append it themselves to the returned slice
/// before padding to width.
///
/// Used by both the analysis tab's sector breakdown rows and the
/// review tab's per-holding sector cells, so a sector name that
/// overflows in one place overflows the same way in the other.
pub fn truncateToCols(content: []const u8, max_cols: usize) []const u8 {
if (max_cols == 0) return content[0..0];
var byte_idx: usize = 0;
var col_idx: usize = 0;
while (byte_idx < content.len and col_idx < max_cols) {
const b = content[byte_idx];
const seq_len: usize = if (b < 0x80)
1
else if (b < 0xC0)
// Continuation byte at the head of a sequence is malformed;
// skip one byte to avoid an infinite loop.
1
else if (b < 0xE0)
2
else if (b < 0xF0)
3
else
4;
if (byte_idx + seq_len > content.len) break;
byte_idx += seq_len;
col_idx += 1;
}
return content[0..byte_idx];
}
// ── Percent / Sharpe / "no-data" cell formatters ─────────────
//
// Shared formatters used by the review tab + view, and any other
// surface that needs to render decimals as 1.5%-style percent
// strings, optionally with a `+` for positives, optionally with
// a trailing reweight asterisk. Multiple TUI tabs and CLI
// commands had near-identical inline copies of this logic before
// these helpers existed; keep callers using these so a future
// "render percent like X" decision lands in one place.
/// Sentinel rendered for null inputs in `fmtPctOpt`/`fmtSharpeOpt`.
/// Chosen to match the rest of the codebase's "no data" convention
/// (`—`, the em-dash). Public so layout code can compute its
/// display width consistently.
pub const no_data_sentinel: []const u8 = "";
/// Options for `fmtPctOpt`.
pub const PctOpts = struct {
/// Number of decimal places. Most surfaces use 1 (review table,
/// analysis bars); a few use 2 (perf command's "ann." rows via
/// `performance.formatReturn`). Clamped at 6 to keep the
/// stack buffer math sane.
decimals: u8 = 1,
/// Prefix `+` on positive values. Used by trailing-return cells
/// where the user reads the sign as a win/loss; not used by
/// vol or magnitude-only fields where everything is positive
/// by definition.
signed: bool = false,
/// Append a trailing `*` asterisk. Review tab uses this to mark
/// portfolio-totals cells whose math required dropping a
/// holding from the window.
asterisk: bool = false,
};
/// Format an optional decimal as a percent string (e.g. `0.1234`
/// -> `"12.3%"`). Returns the `no_data_sentinel` when the input
/// is null. Buffer must be at least ~16 bytes for typical inputs.
///
/// One formatter for every percent-shaped cell - review tab,
/// review CLI command, and (future) any other surface. Avoids
/// the proliferation of near-duplicate `formatPctOpt` /
/// `printSignedPct` / `fmtPercent` helpers each tab used to
/// carry around.
pub fn fmtPctOpt(buf: []u8, v: ?f64, opts: PctOpts) []const u8 {
const val = v orelse return no_data_sentinel;
return fmtPct(buf, val, opts);
}
/// Same as `fmtPctOpt` but takes a non-optional value.
pub fn fmtPct(buf: []u8, v: f64, opts: PctOpts) []const u8 {
const sign: []const u8 = if (opts.signed and v >= 0) "+" else "";
const star: []const u8 = if (opts.asterisk) "*" else "";
return switch (opts.decimals) {
0 => std.fmt.bufPrint(buf, "{s}{d:.0}%{s}", .{ sign, v * 100.0, star }),
1 => std.fmt.bufPrint(buf, "{s}{d:.1}%{s}", .{ sign, v * 100.0, star }),
2 => std.fmt.bufPrint(buf, "{s}{d:.2}%{s}", .{ sign, v * 100.0, star }),
3 => std.fmt.bufPrint(buf, "{s}{d:.3}%{s}", .{ sign, v * 100.0, star }),
else => std.fmt.bufPrint(buf, "{s}{d:.4}%{s}", .{ sign, v * 100.0, star }),
} catch "?";
}
/// Options for `fmtSharpeOpt`. Sharpe is a unitless ratio so it
/// renders as a plain decimal (no `%`), typically two places.
pub const SharpeOpts = struct {
decimals: u8 = 2,
asterisk: bool = false,
};
/// Format an optional Sharpe ratio (or any unitless ratio) as a
/// fixed-decimal string. Returns the `no_data_sentinel` when the
/// input is null.
pub fn fmtSharpeOpt(buf: []u8, v: ?f64, opts: SharpeOpts) []const u8 {
const val = v orelse return no_data_sentinel;
const star: []const u8 = if (opts.asterisk) "*" else "";
return switch (opts.decimals) {
0 => std.fmt.bufPrint(buf, "{d:.0}{s}", .{ val, star }),
1 => std.fmt.bufPrint(buf, "{d:.1}{s}", .{ val, star }),
2 => std.fmt.bufPrint(buf, "{d:.2}{s}", .{ val, star }),
else => std.fmt.bufPrint(buf, "{d:.3}{s}", .{ val, star }),
} catch "?";
}
/// Render an em-dash (`—`) horizontally centered inside a cell
/// of `width` display columns, padded with spaces on both
/// sides. Used for table cells where the value is unavailable
/// (e.g. illiquid totals on imported-only history rows).
///
/// Writes into `buf` and returns a slice of it. `buf` should be
/// at least `width + 2` bytes - the em-dash itself is 3 bytes /
/// 1 column, so the returned byte length is `width + 2` (one
/// 3-byte multibyte sequence in a width-col cell).
pub fn centerDash(buf: []u8, width: usize) []const u8 {
const dash = "";
const pad = (width -| 1) / 2;
var pos: usize = 0;
// Left padding (ASCII spaces - 1 byte = 1 col).
while (pos < pad and pos < buf.len) : (pos += 1) buf[pos] = ' ';
// Dash glyph (3 bytes, 1 col).
if (pos + dash.len <= buf.len) {
@memcpy(buf[pos .. pos + dash.len], dash);
pos += dash.len;
}
// Trailing padding: keep filling spaces until total *display
// columns* hits `width`. We can't reuse the `pos < width`
// shortcut from the left-pad loop because `pos` is now in
// bytes (post-dash) while `width` is in columns.
return padRightToCols(buf, buf[0..pos], width);
}
// ── Date / financial helpers ─────────────────────────────────
/// Get today's date from the system clock.
///
/// Takes `io` because reading wall-clock time is a side-effecting
/// operation in Zig 0.16+. For pure date math in tests, construct
/// dates directly via `Date.fromYmd` instead.
pub fn todayDate(io: std.Io) Date {
const ts = std.Io.Timestamp.now(io, .real).toSeconds();
const days: i32 = @intCast(@divFloor(ts, std.time.s_per_day));
return .{ .days = days };
}
/// English pluralization for "(N day[s])" display suffixes. Returns
/// `""` for `n == 1`, `"s"` otherwise. Used across the CLI and TUI
/// anywhere a day-count is printed inline.
pub fn dayPlural(n: i32) []const u8 {
return if (n == 1) "" else "s";
}
/// Return "LT" if held > 1 year from `open_date` to `as_of`, "ST" otherwise.
/// Caller passes today (or a backfill date) as `as_of`.
pub fn capitalGainsIndicator(as_of: Date, open_date: Date) []const u8 {
return if (as_of.days - open_date.days > 365) "LT" else "ST";
}
/// Return a slice view of candles on or after the given date (no allocation).
pub fn filterCandlesFrom(candles: []const Candle, from: Date) []const Candle {
var lo: usize = 0;
var hi: usize = candles.len;
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
if (candles[mid].date.lessThan(from)) {
lo = mid + 1;
} else {
hi = mid;
}
}
if (lo >= candles.len) return candles[0..0];
return candles[lo..];
}
/// An absolute and percentage price change.
pub const PctChange = struct {
/// `price - base`, in price units.
change: f64,
/// `(price - base) / base * 100`, a percentage.
pct: f64,
};
/// Absolute and percentage change from `base` to `price`. Returns null
/// when `base <= 0`, where a percentage isn't meaningful (e.g. a missing
/// previous close). The single source of truth for the day-over-day and
/// chart-window change figures shared by the `quote` CLI command and the
/// TUI quote tab.
pub fn pctChange(price: f64, base: f64) ?PctChange {
if (base <= 0) return null;
const change = price - base;
return .{ .change = change, .pct = (change / base) * 100.0 };
}
/// Change from the first candle of the most-recent `window_count` candles
/// (the chart's left edge) to `price` (the current price). Used to label
/// the "Change (<span>)" row in both the `quote` CLI command and the TUI
/// quote tab. `window_count` is clamped to the available candles. Returns
/// null when the window holds fewer than 2 candles or its first close is
/// non-positive - i.e. there's no meaningful window to measure.
pub fn windowChange(candles: []const Candle, window_count: usize, price: f64) ?PctChange {
const win_n = @min(candles.len, window_count);
if (win_n < 2) return null;
return pctChange(price, candles[candles.len - win_n].close);
}
// ── Options helpers ──────────────────────────────────────────
/// Filter options contracts to +/- N strikes from ATM.
pub fn filterNearMoney(contracts: []const OptionContract, atm: f64, n: usize) []const OptionContract {
if (atm <= 0 or contracts.len == 0) return contracts;
var best_idx: usize = 0;
var best_dist: f64 = @abs(contracts[0].strike - atm);
for (contracts, 0..) |c, i| {
const dist = @abs(c.strike - atm);
if (dist < best_dist) {
best_dist = dist;
best_idx = i;
}
}
const start = if (best_idx >= n) best_idx - n else 0;
const end = @min(best_idx + n + 1, contracts.len);
return contracts[start..end];
}
/// Check if an expiration date is a standard monthly (3rd Friday of the month).
pub fn isMonthlyExpiration(date: Date) bool {
const dow = date.dayOfWeek(); // 0=Mon..4=Fri
if (dow != 4) return false; // Must be Friday
const d = date.day();
return d >= 15 and d <= 21; // 3rd Friday is between 15th and 21st
}
/// Convert a string to title case ("TECHNOLOGY" -> "Technology", "CONSUMER CYCLICAL" -> "Consumer Cyclical").
/// Writes into a caller-provided buffer and returns the slice.
pub fn toTitleCase(buf: []u8, s: []const u8) []const u8 {
const len = @min(s.len, buf.len);
var capitalize_next = true;
for (s[0..len], 0..) |c, i| {
if (c == ' ') {
buf[i] = ' ';
capitalize_next = true;
} else if (capitalize_next) {
buf[i] = std.ascii.toUpper(c);
capitalize_next = false;
} else {
buf[i] = std.ascii.toLower(c);
}
}
return buf[0..len];
}
/// Format an options contract line: strike + last + bid + ask + volume + OI + IV.
pub fn fmtContractLine(buf: []u8, prefix: []const u8, c: OptionContract) []const u8 {
var last_buf: [12]u8 = undefined;
const last_str = if (c.last_price) |p| std.fmt.bufPrint(&last_buf, "{d:>10.2}", .{p}) catch "--" else "--";
var bid_buf: [12]u8 = undefined;
const bid_str = if (c.bid) |b| std.fmt.bufPrint(&bid_buf, "{d:>10.2}", .{b}) catch "--" else "--";
var ask_buf: [12]u8 = undefined;
const ask_str = if (c.ask) |a| std.fmt.bufPrint(&ask_buf, "{d:>10.2}", .{a}) catch "--" else "--";
var vol_buf: [12]u8 = undefined;
const vol_str = if (c.volume) |v| std.fmt.bufPrint(&vol_buf, "{d:>10}", .{v}) catch "--" else "--";
var oi_buf: [10]u8 = undefined;
const oi_str = if (c.open_interest) |oi| std.fmt.bufPrint(&oi_buf, "{d:>8}", .{oi}) catch "--" else "--";
var iv_buf: [10]u8 = undefined;
const iv_str = if (c.implied_volatility) |iv| std.fmt.bufPrint(&iv_buf, "{d:>6.1}%", .{iv * 100.0}) catch "--" else "--";
return std.fmt.bufPrint(buf, "{s}{d:>10.2} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}", .{
prefix, c.strike, last_str, bid_str, ask_str, vol_str, oi_str, iv_str,
}) catch "";
}
// ── Portfolio helpers ────────────────────────────────────────
/// Sort lots: open lots first (date descending), closed lots last (date descending).
/// Pass `as_of` as the open/closed reference point; avoids needing an
/// Io in the sort callback.
pub fn lotSortFn(as_of: Date, a: Lot, b: Lot) bool {
const a_open = a.isOpen(as_of);
const b_open = b.isOpen(as_of);
if (a_open and !b_open) return true; // open before closed
if (!a_open and b_open) return false;
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;
}
/// Sort lots by maturity date (earliest first), then by symbol name.
/// Lots without maturity sort last.
pub fn lotMaturityThenSymbolSortFn(_: 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);
if (ad != bd) return ad < bd;
return std.mem.lessThan(u8, a.symbol, b.symbol);
}
// ── Shared style intent ──────────────────────────────────────
/// Semantic style intent - renderers map this to platform-specific styles.
/// Used by view models (e.g. views/portfolio_sections.zig) and renderers.
pub const StyleIntent = enum {
normal, // default text
muted, // dim/secondary (expired items)
positive, // green (gains, premium received)
negative, // red (losses, premium paid)
warning, // yellow (stale data, drift)
accent, // purple - section headers, primary series in legends
info, // cyan - informational/overlay content, secondary legend items
};
/// 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(as_of: Date, lots: []const Lot) DripAggregation {
var result: DripAggregation = .{};
for (lots) |lot| {
if (!lot.drip) continue;
const is_lt = std.mem.eql(u8, capitalGainsIndicator(as_of, 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;
}
/// Format a DRIP summary line: "ST: 12 DRIP lots, 3.5 shares, avg $45.67 (2023-01 to 2024-06)"
pub fn fmtDripSummary(buf: []u8, label: []const u8, summary: DripSummary) []const u8 {
var d1_buf: [10]u8 = undefined;
var d2_buf: [10]u8 = undefined;
const d1: []const u8 = if (summary.first_date) |d| blk: {
const s = std.fmt.bufPrint(&d1_buf, "{f}", .{d}) catch break :blk "?";
break :blk s[0..7];
} else "?";
const d2: []const u8 = if (summary.last_date) |d| blk: {
const s = std.fmt.bufPrint(&d2_buf, "{f}", .{d}) catch break :blk "?";
break :blk s[0..7];
} else "?";
return std.fmt.bufPrint(buf, "{s}: {d} DRIP lots, {d:.1} shares, avg {f} ({s} to {s})", .{
label,
summary.lot_count,
summary.shares,
Money.from(summary.avgCost()),
d1,
d2,
}) catch "?";
}
// ── 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 = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(abs_val)}) catch "$?";
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 = std.fmt.bufPrint(&db, "{f}", .{e.date}) catch "????-??-??";
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 vb: [32]u8 = undefined;
return std.fmt.bufPrint(buf, " {f} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}", .{
candle.date.padLeft(12), 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 "?";
}
}
// ── ANSI color helpers (for CLI) ─────────────────────────────
/// Determine whether to use ANSI color output.
/// Uses std.Io.Terminal.Mode.detect which handles TTY detection, NO_COLOR,
/// CLICOLOR_FORCE, and Windows console API cross-platform.
pub fn shouldUseColor(io: std.Io, environ_map: *const std.process.Environ.Map, no_color_flag: bool) bool {
if (no_color_flag) return false;
const NO_COLOR = if (environ_map.get("NO_COLOR")) |v| v.len > 0 else false;
const CLICOLOR_FORCE = if (environ_map.get("CLICOLOR_FORCE")) |v| v.len > 0 else false;
const mode = std.Io.Terminal.Mode.detect(io, std.Io.File.stdout(), NO_COLOR, CLICOLOR_FORCE) catch return false;
return mode != .no_color;
}
/// Write an ANSI 24-bit foreground color escape.
pub fn ansiSetFg(out: *std.Io.Writer, r: u8, g: u8, b: u8) !void {
try out.print("\x1b[38;2;{d};{d};{d}m", .{ r, g, b });
}
/// Write ANSI bold.
pub fn ansiBold(out: *std.Io.Writer) !void {
try out.writeAll("\x1b[1m");
}
/// Reset all ANSI attributes.
pub fn ansiReset(out: *std.Io.Writer) !void {
try out.writeAll("\x1b[0m");
}
// ── Tests ────────────────────────────────────────────────────
test "fmtIntCommas" {
var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("0", fmtIntCommas(&buf, 0));
try std.testing.expectEqualStrings("999", fmtIntCommas(&buf, 999));
try std.testing.expectEqualStrings("1,000", fmtIntCommas(&buf, 1000));
try std.testing.expectEqualStrings("1,234,567", fmtIntCommas(&buf, 1234567));
}
test "fmtLargeNum" {
// Sub-million: formatted as raw number
const small = fmtLargeNum(12345.0);
try std.testing.expect(std.mem.startsWith(u8, &small, "12345"));
// Millions
const mil = fmtLargeNum(45_600_000.0);
try std.testing.expect(std.mem.startsWith(u8, &mil, "45.6M"));
// Billions
const bil = fmtLargeNum(1_500_000_000.0);
try std.testing.expect(std.mem.startsWith(u8, &bil, "1.5B"));
// Trillions
const tril = fmtLargeNum(2_300_000_000_000.0);
try std.testing.expect(std.mem.startsWith(u8, &tril, "2.3T"));
}
test "fmtCashHeader" {
var buf: [128]u8 = undefined;
const header = fmtCashHeader(&buf);
try std.testing.expect(std.mem.startsWith(u8, header, " Account"));
try std.testing.expect(std.mem.indexOf(u8, header, "Balance") != null);
try std.testing.expect(std.mem.indexOf(u8, header, "Note") != null);
}
test "fmtCashRow" {
var buf: [128]u8 = undefined;
const row = fmtCashRow(&buf, "Savings Account", 5000.00, "Emergency fund");
try std.testing.expect(std.mem.indexOf(u8, row, "Savings Account") != null);
try std.testing.expect(std.mem.indexOf(u8, row, "$5,000.00") != null);
try std.testing.expect(std.mem.indexOf(u8, row, "Emergency fund") != null);
}
test "fmtCashRow no note" {
var buf: [128]u8 = undefined;
const row = fmtCashRow(&buf, "Checking", 1234.56, null);
try std.testing.expect(std.mem.indexOf(u8, row, "Checking") != null);
try std.testing.expect(std.mem.indexOf(u8, row, "$1,234.56") != null);
}
test "fmtCashSep" {
var buf: [128]u8 = undefined;
const sep = fmtCashSep(&buf);
// Should contain dashes
try std.testing.expect(std.mem.indexOf(u8, sep, "---") != null);
try std.testing.expect(std.mem.startsWith(u8, sep, " "));
}
test "fmtCashTotal" {
var buf: [128]u8 = undefined;
const total = fmtCashTotal(&buf, 25000.00);
try std.testing.expect(std.mem.indexOf(u8, total, "TOTAL") != null);
try std.testing.expect(std.mem.indexOf(u8, total, "$25,000.00") != null);
}
test "fmtIlliquidHeader" {
var buf: [128]u8 = undefined;
const header = fmtIlliquidHeader(&buf);
try std.testing.expect(std.mem.startsWith(u8, header, " Asset"));
try std.testing.expect(std.mem.indexOf(u8, header, "Value") != null);
try std.testing.expect(std.mem.indexOf(u8, header, "Note") != null);
}
test "filterCandlesFrom" {
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 1, 3), .open = 101, .high = 101, .low = 101, .close = 101, .adj_close = 101, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 1, 4), .open = 102, .high = 102, .low = 102, .close = 102, .adj_close = 102, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 1, 5), .open = 103, .high = 103, .low = 103, .close = 103, .adj_close = 103, .volume = 1000 },
};
// Date before all candles: returns all
const all = filterCandlesFrom(&candles, Date.fromYmd(2024, 1, 1));
try std.testing.expectEqual(@as(usize, 4), all.len);
// Date in the middle: returns subset
const mid = filterCandlesFrom(&candles, Date.fromYmd(2024, 1, 3));
try std.testing.expectEqual(@as(usize, 3), mid.len);
try std.testing.expect(mid[0].date.eql(Date.fromYmd(2024, 1, 3)));
// Date after all candles: returns empty
const none = filterCandlesFrom(&candles, Date.fromYmd(2025, 1, 1));
try std.testing.expectEqual(@as(usize, 0), none.len);
// Exact match on first date
const exact = filterCandlesFrom(&candles, Date.fromYmd(2024, 1, 2));
try std.testing.expectEqual(@as(usize, 4), exact.len);
// Empty slice
const empty: []const Candle = &.{};
const from_empty = filterCandlesFrom(empty, Date.fromYmd(2024, 1, 1));
try std.testing.expectEqual(@as(usize, 0), from_empty.len);
}
fn testCandle(close: f64) Candle {
return .{ .date = Date.fromYmd(2024, 1, 2), .open = close, .high = close, .low = close, .close = close, .adj_close = close, .volume = 1000 };
}
test "pctChange: up and down moves" {
const up = pctChange(110.0, 100.0).?;
try std.testing.expectApproxEqAbs(@as(f64, 10.0), up.change, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), up.pct, 1e-9);
const down = pctChange(90.0, 100.0).?;
try std.testing.expectApproxEqAbs(@as(f64, -10.0), down.change, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, -10.0), down.pct, 1e-9);
}
test "pctChange: non-positive base returns null" {
try std.testing.expect(pctChange(100.0, 0.0) == null);
try std.testing.expect(pctChange(100.0, -5.0) == null);
}
test "windowChange: measures from the first candle in the window" {
const candles = [_]Candle{ testCandle(100.0), testCandle(110.0), testCandle(125.0) };
// Full window: base = first close (100), price 125 -> +25 (+25%).
const full = windowChange(&candles, 3, 125.0).?;
try std.testing.expectApproxEqAbs(@as(f64, 25.0), full.change, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, 25.0), full.pct, 1e-9);
}
test "windowChange: window_count clamps to available candles" {
const candles = [_]Candle{ testCandle(100.0), testCandle(110.0) };
// Asking for 60 but only 2 exist -> base is the earliest (100).
const wc = windowChange(&candles, 60, 121.0).?;
try std.testing.expectApproxEqAbs(@as(f64, 21.0), wc.change, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, 21.0), wc.pct, 1e-9);
}
test "windowChange: sub-window picks candles[len-window_count] as base" {
const candles = [_]Candle{ testCandle(50.0), testCandle(100.0), testCandle(120.0) };
// window_count=2 -> base is candles[len-2] = 100, not 50.
const wc = windowChange(&candles, 2, 120.0).?;
try std.testing.expectApproxEqAbs(@as(f64, 20.0), wc.change, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, 20.0), wc.pct, 1e-9);
}
test "windowChange: fewer than 2 candles in the window returns null" {
const one = [_]Candle{testCandle(100.0)};
try std.testing.expect(windowChange(&one, 60, 110.0) == null);
const empty: []const Candle = &.{};
try std.testing.expect(windowChange(empty, 60, 110.0) == null);
// window_count < 2 yields null even with enough candles.
const two = [_]Candle{ testCandle(100.0), testCandle(110.0) };
try std.testing.expect(windowChange(&two, 1, 110.0) == null);
}
test "windowChange: non-positive base close returns null" {
const candles = [_]Candle{ testCandle(0.0), testCandle(110.0) };
try std.testing.expect(windowChange(&candles, 2, 110.0) == null);
}
test "filterNearMoney" {
const exp = Date.fromYmd(2024, 3, 15);
const contracts = [_]OptionContract{
.{ .strike = 90, .contract_type = .call, .expiration = exp },
.{ .strike = 95, .contract_type = .call, .expiration = exp },
.{ .strike = 100, .contract_type = .call, .expiration = exp },
.{ .strike = 105, .contract_type = .call, .expiration = exp },
.{ .strike = 110, .contract_type = .call, .expiration = exp },
.{ .strike = 115, .contract_type = .call, .expiration = exp },
};
// ATM at 100, filter to +/- 2 strikes
const near = filterNearMoney(&contracts, 100, 2);
try std.testing.expectEqual(@as(usize, 5), near.len);
try std.testing.expectApproxEqAbs(@as(f64, 90), near[0].strike, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 110), near[near.len - 1].strike, 0.01);
// ATM <= 0: returns full slice
const full = filterNearMoney(&contracts, 0, 2);
try std.testing.expectEqual(@as(usize, 6), full.len);
// Empty contracts
const empty: []const OptionContract = &.{};
const from_empty = filterNearMoney(empty, 100, 2);
try std.testing.expectEqual(@as(usize, 0), from_empty.len);
}
test "isMonthlyExpiration" {
// 2024-01-19 is a Friday and the 3rd Friday of January
try std.testing.expect(isMonthlyExpiration(Date.fromYmd(2024, 1, 19)));
// 2024-02-16 is a Friday and the 3rd Friday of February
try std.testing.expect(isMonthlyExpiration(Date.fromYmd(2024, 2, 16)));
// 2024-01-12 is a Friday but the 2nd Friday (day 12 < 15)
try std.testing.expect(!isMonthlyExpiration(Date.fromYmd(2024, 1, 12)));
// 2024-01-26 is a Friday but the 4th Friday (day 26 > 21)
try std.testing.expect(!isMonthlyExpiration(Date.fromYmd(2024, 1, 26)));
// 2024-01-17 is a Wednesday (not a Friday)
try std.testing.expect(!isMonthlyExpiration(Date.fromYmd(2024, 1, 17)));
}
test "toTitleCase" {
var buf: [64]u8 = undefined;
try std.testing.expectEqualStrings("Technology", toTitleCase(&buf, "TECHNOLOGY"));
try std.testing.expectEqualStrings("Consumer Cyclical", toTitleCase(&buf, "CONSUMER CYCLICAL"));
try std.testing.expectEqualStrings("Healthcare", toTitleCase(&buf, "HEALTHCARE"));
try std.testing.expectEqualStrings("Technology", toTitleCase(&buf, "Technology"));
try std.testing.expectEqualStrings("Unknown", toTitleCase(&buf, "Unknown"));
}
test "lotSortFn" {
const open_new = Lot{
.symbol = "A",
.shares = 1,
.open_date = Date.fromYmd(2024, 6, 1),
.open_price = 100,
};
const open_old = Lot{
.symbol = "B",
.shares = 1,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 100,
};
const closed = Lot{
.symbol = "C",
.shares = 1,
.open_date = Date.fromYmd(2024, 3, 1),
.open_price = 100,
.close_date = Date.fromYmd(2024, 6, 1),
.close_price = 110,
};
// Open before closed
try std.testing.expect(lotSortFn(Date.fromYmd(2026, 5, 8), open_new, closed));
try std.testing.expect(!lotSortFn(Date.fromYmd(2026, 5, 8), closed, open_new));
// Among open lots: newest first
try std.testing.expect(lotSortFn(Date.fromYmd(2026, 5, 8), open_new, open_old));
try std.testing.expect(!lotSortFn(Date.fromYmd(2026, 5, 8), open_old, open_new));
}
test "lotMaturitySortFn" {
const with_maturity = Lot{
.symbol = "CD1",
.shares = 10000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 100,
.security_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,
.security_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,
.security_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 },
};
// as_of pinned: 2023 lot is >1 year old (LT), 2025 lots are <1 year (ST).
const as_of = Date.fromYmd(2026, 1, 1);
const agg = aggregateDripLots(as_of, &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(Date.fromYmd(2026, 1, 1), &lots);
try std.testing.expect(agg.st.isEmpty());
try std.testing.expect(agg.lt.isEmpty());
}
test "fmtContractLine" {
var buf: [128]u8 = undefined;
const contract = OptionContract{
.strike = 150.0,
.contract_type = .call,
.expiration = Date.fromYmd(2024, 3, 15),
.last_price = 5.25,
.bid = 5.10,
.ask = 5.40,
.volume = 1234,
.open_interest = 5678,
.implied_volatility = 0.25,
};
const line = fmtContractLine(&buf, "C ", contract);
try std.testing.expect(std.mem.indexOf(u8, line, "150.00") != null);
try std.testing.expect(std.mem.indexOf(u8, line, "5.25") != null);
try std.testing.expect(std.mem.indexOf(u8, line, "1234") != null);
}
test "fmtContractLine null fields" {
var buf: [128]u8 = undefined;
const contract = OptionContract{
.strike = 200.0,
.contract_type = .put,
.expiration = Date.fromYmd(2024, 6, 21),
};
const line = fmtContractLine(&buf, "P ", contract);
try std.testing.expect(std.mem.indexOf(u8, line, "200.00") != null);
// Null fields should show "--"
try std.testing.expect(std.mem.indexOf(u8, line, "--") != null);
}
test "fmtGainLoss positive" {
var buf: [32]u8 = undefined;
const result = fmtGainLoss(&buf, 1234.56);
try std.testing.expect(result.positive);
try std.testing.expect(std.mem.startsWith(u8, result.text, "+$"));
}
test "fmtGainLoss negative" {
var buf: [32]u8 = undefined;
const result = fmtGainLoss(&buf, -500.00);
try std.testing.expect(!result.positive);
try std.testing.expect(std.mem.startsWith(u8, result.text, "-$"));
}
test "fmtEarningsRow with data" {
var buf: [128]u8 = undefined;
const e = EarningsEvent{
.symbol = "AAPL",
.date = Date.fromYmd(2024, 1, 25),
.quarter = 1,
.estimate = 2.10,
.actual = 2.18,
.surprise = 0.08,
.surprise_percent = 3.8,
};
const result = fmtEarningsRow(&buf, e);
try std.testing.expect(!result.is_future);
try std.testing.expect(result.is_positive);
try std.testing.expect(std.mem.indexOf(u8, result.text, "Q1") != null);
try std.testing.expect(std.mem.indexOf(u8, result.text, "$2.10") != null);
try std.testing.expect(std.mem.indexOf(u8, result.text, "$2.18") != null);
}
test "fmtEarningsRow future event" {
var buf: [128]u8 = undefined;
const e = EarningsEvent{
.symbol = "AAPL",
.date = Date.fromYmd(2026, 7, 1),
.estimate = 2.50,
};
const result = fmtEarningsRow(&buf, e);
try std.testing.expect(result.is_future);
try std.testing.expect(std.mem.indexOf(u8, result.text, "--") != null); // no actual
}
test "buildBlockBar" {
var buf: [256]u8 = undefined;
// Full bar
const full = buildBlockBar(&buf, 1.0, 10);
try std.testing.expectEqual(@as(usize, 30), full.len); // 10 chars * 3 bytes each
// Empty bar
const empty = buildBlockBar(&buf, 0.0, 10);
try std.testing.expectEqual(@as(usize, 10), empty.len); // 10 spaces
// Half bar: 5 full blocks + 5 spaces = 5*3 + 5 = 20
const half = buildBlockBar(&buf, 0.5, 10);
try std.testing.expectEqual(@as(usize, 20), half.len);
}
test "buildBlockBar: negative weight clamps to empty bar (no crash)" {
// NPORT-P emits negative pct values for leveraged-fund
// liability sleeves (e.g. PTY's repurchase agreement at
// -29.72%). After portfolio-wide aggregation and dilution
// these tend to produce small-magnitude negative weights in
// the Sector breakdown. The renderer must handle them
// safely - render as a 0-width (all-spaces) bar with no
// panic on @intFromFloat.
var buf: [256]u8 = undefined;
const small_neg = buildBlockBar(&buf, -0.003, 10);
try std.testing.expectEqual(@as(usize, 10), small_neg.len);
try std.testing.expectEqualStrings(" ", small_neg);
const large_neg = buildBlockBar(&buf, -1.5, 10);
try std.testing.expectEqual(@as(usize, 10), large_neg.len);
try std.testing.expectEqualStrings(" ", large_neg);
}
test "buildBlockBar: weight > 1.0 clamps to full bar (no overflow)" {
// Symmetric defensive case: if for any reason the caller
// hands us a weight above 1.0 (e.g. the per-fund rather than
// per-portfolio side of the math), the bar should clamp
// rather than write past `total_chars`.
var buf: [256]u8 = undefined;
const overshoot = buildBlockBar(&buf, 1.5, 10);
try std.testing.expectEqual(@as(usize, 30), overshoot.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"));
}
test "fmtTimeAgo: zero before_s returns empty string" {
var buf: [24]u8 = undefined;
try std.testing.expectEqualStrings("", fmtTimeAgo(&buf, 0, 1_700_000_000));
}
test "fmtTimeAgo: before > after renders 'just now'" {
var buf: [24]u8 = undefined;
try std.testing.expectEqualStrings("just now", fmtTimeAgo(&buf, 1_700_000_050, 1_700_000_000));
}
test "fmtTimeAgo: sub-minute delta renders 'just now'" {
var buf: [24]u8 = undefined;
try std.testing.expectEqualStrings("just now", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_059));
}
test "fmtTimeAgo: minutes" {
var buf: [24]u8 = undefined;
// 5m = 300s
try std.testing.expectEqualStrings("5m ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 300));
// 59m = 3540s (still minutes)
try std.testing.expectEqualStrings("59m ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 3540));
}
test "fmtTimeAgo: hours" {
var buf: [24]u8 = undefined;
// 1h exactly
try std.testing.expectEqualStrings("1h ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 3600));
// 5h
try std.testing.expectEqualStrings("5h ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 5 * 3600));
}
test "fmtTimeAgo: days" {
var buf: [24]u8 = undefined;
// 1d = 86_400s
try std.testing.expectEqualStrings("1d ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 86_400));
// 7d
try std.testing.expectEqualStrings("7d ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 7 * 86_400));
}
test "displayCols: ASCII bytes count as 1 col each" {
try std.testing.expectEqual(@as(usize, 0), displayCols(""));
try std.testing.expectEqual(@as(usize, 5), displayCols("hello"));
try std.testing.expectEqual(@as(usize, 10), displayCols("2026-05-08"));
}
test "displayCols: multibyte UTF-8 chars count as 1 col" {
// U+25B6 BLACK RIGHT-POINTING TRIANGLE = 3 bytes / 1 col.
try std.testing.expectEqual(@as(usize, 1), displayCols(""));
try std.testing.expectEqual(@as(usize, 1), displayCols(""));
// U+2014 EM DASH = 3 bytes / 1 col.
try std.testing.expectEqual(@as(usize, 1), displayCols(""));
// Mixed.
try std.testing.expectEqual(@as(usize, 17), displayCols("▶ W of 2026-04-22"));
try std.testing.expectEqual(@as(usize, 10), displayCols("▶ Jan 2026"));
try std.testing.expectEqual(@as(usize, 6), displayCols("▶ 2024"));
}
test "padRightToCols: ASCII content pads to target" {
var buf: [16]u8 = undefined;
@memcpy(buf[0..2], "hi");
const out = padRightToCols(&buf, buf[0..2], 5);
try std.testing.expectEqualStrings("hi ", out);
}
test "padRightToCols: multibyte content pads to display width" {
var buf: [16]u8 = undefined;
const dash = "";
@memcpy(buf[0..dash.len], dash);
// Em-dash is 1 col / 3 bytes. Target 5 cols -> 4 trailing spaces.
// Total bytes: 3 + 4 = 7.
const out = padRightToCols(&buf, buf[0..dash.len], 5);
try std.testing.expectEqual(@as(usize, 7), out.len);
try std.testing.expectEqualStrings("", out);
}
test "padRightToCols: content already at-or-beyond target returns as-is" {
var buf: [16]u8 = undefined;
@memcpy(buf[0..5], "hello");
const out = padRightToCols(&buf, buf[0..5], 5);
try std.testing.expectEqualStrings("hello", out);
const out2 = padRightToCols(&buf, buf[0..5], 3);
try std.testing.expectEqualStrings("hello", out2);
}
test "centerDash: even width centers dash with equal padding" {
var buf: [16]u8 = undefined;
const out = centerDash(&buf, 6);
// 6 cols: pad = (6-1)/2 = 2 left spaces, then dash (1 col / 3
// bytes), then 3 right spaces. Total: 6 cols = 8 bytes.
try std.testing.expectEqual(@as(usize, 6), displayCols(out));
try std.testing.expectEqual(@as(usize, 8), out.len);
try std.testing.expectEqualStrings("", out);
}
test "centerDash: odd width biases dash one col left of center" {
var buf: [16]u8 = undefined;
const out = centerDash(&buf, 5);
// 5 cols: pad = (5-1)/2 = 2 left spaces, dash, 2 right spaces.
// Total: 5 cols = 7 bytes.
try std.testing.expectEqual(@as(usize, 5), displayCols(out));
try std.testing.expectEqual(@as(usize, 7), out.len);
try std.testing.expectEqualStrings("", out);
}
test "centerDash: width 1 emits dash with no padding" {
var buf: [8]u8 = undefined;
const out = centerDash(&buf, 1);
// pad = (1-1)/2 = 0: no left spaces, dash, no right spaces.
try std.testing.expectEqual(@as(usize, 1), displayCols(out));
try std.testing.expectEqualStrings("", out);
}
test "centerDash: width 0 emits empty slice" {
var buf: [8]u8 = undefined;
const out = centerDash(&buf, 0);
// (0 -| 1) / 2 = 0: no left spaces. The `if pos + dash.len
// <= buf.len` does write the dash though, but then
// padRightToCols sees content with display-width 1 against
// a target of 0 and returns the content as-is. So we get a
// bare dash even at width 0. Degenerate case the renderer
// never hits in practice (cell widths are always > 0); lock
// in current behavior.
try std.testing.expectEqualStrings("", out);
}
test "centerDash: typical history-table cell width (31 cols)" {
// This is the actual table_cell_width used in the History
// tab - em-dash centered in 31 columns.
var buf: [40]u8 = undefined;
const out = centerDash(&buf, 31);
// pad = 15, dash (1 col), 15 right spaces. 31 cols = 33 bytes.
try std.testing.expectEqual(@as(usize, 31), displayCols(out));
try std.testing.expectEqual(@as(usize, 33), out.len);
try std.testing.expectEqualStrings("", out);
}
test "centerDash: undersized buffer returns less than `width` cols" {
// Defensive: buffer too small to hold the full padded cell.
// Function falls back to whatever fits without overflowing.
var buf: [4]u8 = undefined;
const out = centerDash(&buf, 10);
// pad = 4 spaces wanted but only 4-byte buf - left-loop fills
// to pos=4, then `pos + dash.len <= buf.len` is `4+3<=4` =
// false, so dash isn't written. Trailing-pad helper sees
// content with 4 cols against target 10, and 4+pad>buf.len
// so it returns content unchanged. Output is 4 spaces.
try std.testing.expectEqualStrings(" ", out);
}
test "truncateToCols: shorter than max returns unchanged" {
try std.testing.expectEqualStrings("Foo", truncateToCols("Foo", 5));
try std.testing.expectEqualStrings("", truncateToCols("", 5));
}
test "truncateToCols: ASCII at boundary" {
try std.testing.expectEqualStrings("Hello", truncateToCols("Hello, world", 5));
try std.testing.expectEqualStrings("Hello", truncateToCols("Hello", 5));
try std.testing.expectEqualStrings("Hell", truncateToCols("Hello", 4));
}
test "truncateToCols: zero max returns empty slice" {
try std.testing.expectEqualStrings("", truncateToCols("Anything", 0));
}
test "truncateToCols: multibyte glyphs counted as one column each" {
// Em-dash is 3 bytes / 1 column. Three em-dashes in 9 bytes,
// counted as 3 columns. Truncating to 2 columns should yield
// the first two em-dashes (6 bytes).
const dashes = "———xyz"; // 3 dashes (9 bytes) + 3 ASCII = 6 cols
try std.testing.expectEqualStrings("——", truncateToCols(dashes, 2));
try std.testing.expectEqualStrings("———", truncateToCols(dashes, 3));
try std.testing.expectEqualStrings("———x", truncateToCols(dashes, 4));
}
test "truncateToCols: never slices a multibyte sequence in half" {
const s = "a—b"; // 'a' (1B/1col), em-dash (3B/1col), 'b' (1B/1col) = 5B/3cols
// Truncating to 1 col gets just 'a'.
try std.testing.expectEqualStrings("a", truncateToCols(s, 1));
// 2 cols gets 'a' + em-dash.
try std.testing.expectEqualStrings("a—", truncateToCols(s, 2));
// 3 cols (full string fits).
try std.testing.expectEqualStrings("a—b", truncateToCols(s, 3));
}
test "fmtPct: default decimals is 1" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("12.3%", fmtPct(&buf, 0.1234, .{}));
}
test "fmtPct: signed positive shows + prefix" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("+12.3%", fmtPct(&buf, 0.1234, .{ .signed = true }));
}
test "fmtPct: signed negative does not double-sign" {
var buf: [16]u8 = undefined;
// {d} already produces the leading `-`; opts.signed only adds `+`.
try std.testing.expectEqualStrings("-5.0%", fmtPct(&buf, -0.05, .{ .signed = true }));
}
test "fmtPct: asterisk appended after percent" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("12.3%*", fmtPct(&buf, 0.1234, .{ .asterisk = true }));
try std.testing.expectEqualStrings("+12.3%*", fmtPct(&buf, 0.1234, .{ .signed = true, .asterisk = true }));
}
test "fmtPct: decimals selector" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("12%", fmtPct(&buf, 0.1234, .{ .decimals = 0 }));
try std.testing.expectEqualStrings("12.34%", fmtPct(&buf, 0.1234, .{ .decimals = 2 }));
try std.testing.expectEqualStrings("12.340%", fmtPct(&buf, 0.1234, .{ .decimals = 3 }));
}
test "fmtPctOpt: null returns sentinel" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings(no_data_sentinel, fmtPctOpt(&buf, null, .{}));
}
test "fmtPctOpt: value forwards to fmtPct with the same opts" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("+12.3%", fmtPctOpt(&buf, 0.1234, .{ .signed = true }));
}
test "fmtSharpeOpt: default 2 decimals" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("1.25", fmtSharpeOpt(&buf, 1.25, .{}));
try std.testing.expectEqualStrings("-0.30", fmtSharpeOpt(&buf, -0.3, .{}));
}
test "fmtSharpeOpt: null returns sentinel" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings(no_data_sentinel, fmtSharpeOpt(&buf, null, .{}));
}
test "fmtSharpeOpt: asterisk and decimals" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("1.25*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true }));
try std.testing.expectEqualStrings("1.3*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true, .decimals = 1 }));
}