zfin/src/format.zig

1391 lines
53 KiB
Zig

//! Shared formatting utilities used by both CLI and TUI.
//!
//! Number formatting (fmtMoney, fmtIntCommas, etc.), financial helpers
//! (capitalGainsIndicator, filterNearMoney), and braille chart computation.
const std = @import("std");
const Date = @import("models/date.zig").Date;
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 = fmtMoney(&money_buf, amount);
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 = fmtMoney(&money_buf, total);
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 fn fmtIlliquidSep(buf: []u8) []const u8 {
return fmtCashSep(buf);
}
/// Format the illiquid total row.
pub fn fmtIlliquidTotal(buf: []u8, total: f64) []const u8 {
return fmtCashTotal(buf, total);
}
// ── Number formatters ────────────────────────────────────────
/// Format a dollar amount with commas and 2 decimals: $1,234.56
pub fn fmtMoney(buf: []u8, amount: f64) []const u8 {
const cents = @as(i64, @intFromFloat(@round(amount * 100.0)));
const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents));
const dollars = abs_cents / 100;
const rem = abs_cents % 100;
var tmp: [24]u8 = undefined;
var pos: usize = tmp.len;
// Cents
pos -= 1;
tmp[pos] = '0' + @as(u8, @intCast(rem % 10));
pos -= 1;
tmp[pos] = '0' + @as(u8, @intCast(rem / 10));
pos -= 1;
tmp[pos] = '.';
// Dollars with commas
var d = dollars;
var digit_count: usize = 0;
if (d == 0) {
pos -= 1;
tmp[pos] = '0';
} else {
while (d > 0) {
if (digit_count > 0 and digit_count % 3 == 0) {
pos -= 1;
tmp[pos] = ',';
}
pos -= 1;
tmp[pos] = '0' + @as(u8, @intCast(d % 10));
d /= 10;
digit_count += 1;
}
}
pos -= 1;
tmp[pos] = '$';
const len = tmp.len - pos;
if (len > buf.len) return "$?";
@memcpy(buf[0..len], tmp[pos..]);
return buf[0..len];
}
/// Format price with 2 decimals (no commas, for per-share prices): $185.23
pub fn fmtMoney2(buf: []u8, amount: f64) []const u8 {
return std.fmt.bufPrint(buf, "${d:.2}", .{amount}) catch "$?";
}
/// 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 a unix timestamp as relative time ("just now", "5m ago", "2h ago", "3d ago").
pub fn fmtTimeAgo(buf: []u8, timestamp: i64) []const u8 {
if (timestamp == 0) return "";
const now = std.time.timestamp();
const delta = now - timestamp;
if (delta < 0) return "just now";
if (delta < 60) return "just now";
if (delta < 3600) {
return std.fmt.bufPrint(buf, "{d}m ago", .{@as(u64, @intCast(@divFloor(delta, 60)))}) catch "?";
}
if (delta < 86400) {
return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divFloor(delta, 3600)))}) catch "?";
}
return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divFloor(delta, 86400)))}) 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 = .{' '} ** 15;
if (val >= 1_000_000_000_000) {
_ = std.fmt.bufPrint(&result, "{d:.1}T", .{val / 1_000_000_000_000}) catch {};
} else if (val >= 1_000_000_000) {
_ = std.fmt.bufPrint(&result, "{d:.1}B", .{val / 1_000_000_000}) catch {};
} else if (val >= 1_000_000) {
_ = std.fmt.bufPrint(&result, "{d:.1}M", .{val / 1_000_000}) catch {};
} else {
_ = std.fmt.bufPrint(&result, "{d:.0}", .{val}) catch {};
}
return result;
}
// ── Date / financial helpers ─────────────────────────────────
/// Get today's date.
pub fn todayDate() Date {
const ts = std.time.timestamp();
const days: i32 = @intCast(@divFloor(ts, 86400));
return .{ .days = days };
}
/// Return "LT" if held > 1 year from open_date to today, "ST" otherwise.
pub fn capitalGainsIndicator(open_date: Date) []const u8 {
const today = todayDate();
return if (today.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..];
}
// ── 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
}
/// Check if a string looks like a CUSIP (9 alphanumeric characters).
/// CUSIPs have 6 alphanumeric issuer chars + 2 issue chars + 1 check digit.
/// This is a heuristic — it won't catch all CUSIPs and may have false positives.
pub fn isCusipLike(s: []const u8) bool {
if (s.len != 9) return false;
// Must contain at least one digit (all-alpha would be a ticker)
var has_digit = false;
for (s) |c| {
if (!std.ascii.isAlphanumeric(c)) return false;
if (std.ascii.isDigit(c)) has_digit = true;
}
return has_digit;
}
/// 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(alloc: std.mem.Allocator, 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.allocPrint(alloc, "{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,
});
}
// ── Portfolio helpers ────────────────────────────────────────
/// Sort lots: open lots first (date descending), closed lots last (date descending).
pub fn lotSortFn(_: void, a: Lot, b: Lot) bool {
const a_open = a.isOpen();
const b_open = b.isOpen();
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;
}
/// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket.
pub const DripSummary = struct {
lot_count: usize = 0,
shares: f64 = 0,
cost: f64 = 0,
first_date: ?Date = null,
last_date: ?Date = null,
pub fn avgCost(self: DripSummary) f64 {
return if (self.shares > 0) self.cost / self.shares else 0;
}
pub fn isEmpty(self: DripSummary) bool {
return self.lot_count == 0;
}
};
/// Aggregated ST and LT DRIP summaries.
pub const DripAggregation = struct {
st: DripSummary = .{},
lt: DripSummary = .{},
};
/// Aggregate DRIP lots into short-term and long-term buckets.
/// Classifies using `capitalGainsIndicator` (LT if held > 1 year).
pub fn aggregateDripLots(lots: []const Lot) DripAggregation {
var result: DripAggregation = .{};
for (lots) |lot| {
if (!lot.drip) continue;
const is_lt = std.mem.eql(u8, capitalGainsIndicator(lot.open_date), "LT");
const bucket: *DripSummary = if (is_lt) &result.lt else &result.st;
bucket.lot_count += 1;
bucket.shares += lot.shares;
bucket.cost += lot.costBasis();
if (bucket.first_date == null or lot.open_date.days < bucket.first_date.?.days)
bucket.first_date = lot.open_date;
if (bucket.last_date == null or lot.open_date.days > bucket.last_date.?.days)
bucket.last_date = lot.open_date;
}
return result;
}
// ── Color helpers ────────────────────────────────────────────
// ── Shared rendering helpers (CLI + TUI) ─────────────────────
/// Layout constants for analysis breakdown views.
pub const analysis_label_width: usize = 24;
pub const analysis_bar_width: usize = 30;
/// Format a signed gain/loss amount: "+$1,234.56" or "-$1,234.56".
/// Returns the formatted string and whether the value is non-negative.
pub const GainLossResult = struct { text: []const u8, positive: bool };
pub fn fmtGainLoss(buf: []u8, pnl: f64) GainLossResult {
const positive = pnl >= 0;
const abs_val = if (positive) pnl else -pnl;
var money_buf: [24]u8 = undefined;
const money = fmtMoney(&money_buf, abs_val);
const sign: []const u8 = if (positive) "+" else "-";
const text = std.fmt.bufPrint(buf, "{s}{s}", .{ sign, money }) catch "?";
return .{ .text = text, .positive = positive };
}
/// Format a single earnings event row (without color/style).
/// Returns the formatted text and color hint.
pub const EarningsRowResult = struct {
text: []const u8,
is_future: bool,
is_positive: bool,
};
pub fn fmtEarningsRow(buf: []u8, e: EarningsEvent) EarningsRowResult {
const is_future = e.isFuture();
const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true;
var db: [10]u8 = undefined;
const date_str = e.date.format(&db);
var q_buf: [4]u8 = undefined;
const q_str = if (e.quarter) |q| std.fmt.bufPrint(&q_buf, "Q{d}", .{q}) catch "--" else "--";
var est_buf: [12]u8 = undefined;
const est_str = if (e.estimate) |est| std.fmt.bufPrint(&est_buf, "${d:.2}", .{est}) catch "--" else "--";
var act_buf: [12]u8 = undefined;
const act_str = if (e.actual) |act| std.fmt.bufPrint(&act_buf, "${d:.2}", .{act}) catch "--" else "--";
var surp_buf: [12]u8 = undefined;
const surp_str: []const u8 = if (e.surpriseAmount()) |s|
(if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?")
else
"--";
var surp_pct_buf: [12]u8 = undefined;
const surp_pct_str: []const u8 = if (e.surprisePct()) |sp|
(if (sp >= 0) std.fmt.bufPrint(&surp_pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&surp_pct_buf, "{d:.1}%", .{sp}) catch "?")
else
"--";
const text = std.fmt.bufPrint(buf, "{s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{
date_str, q_str, est_str, act_str, surp_str, surp_pct_str,
}) catch "";
return .{ .text = text, .is_future = is_future, .is_positive = surprise_positive };
}
/// Format a single returns table row: " label +15.0% ann."
/// Returns null if the period result is null (N/A).
pub const ReturnsRowResult = struct {
price_str: []const u8,
total_str: ?[]const u8,
price_positive: bool,
suffix: []const u8,
};
pub fn fmtReturnsRow(
price_buf: []u8,
total_buf: []u8,
price_result: ?PerformanceResult,
total_result: ?PerformanceResult,
annualize: bool,
) ReturnsRowResult {
const performance = @import("analytics/performance.zig");
const ps: []const u8 = if (price_result) |r| blk: {
const val = if (annualize) r.annualized_return orelse r.total_return else r.total_return;
break :blk performance.formatReturn(price_buf, val);
} else "N/A";
const price_positive = if (price_result) |r| blk: {
const val = if (annualize) r.annualized_return orelse r.total_return else r.total_return;
break :blk val >= 0;
} else true;
const ts: ?[]const u8 = if (total_result) |r| blk: {
const val = if (annualize) r.annualized_return orelse r.total_return else r.total_return;
break :blk performance.formatReturn(total_buf, val);
} else null;
return .{
.price_str = ps,
.total_str = ts,
.price_positive = price_positive,
.suffix = if (annualize) " ann." else "",
};
}
/// Build a block-element bar using Unicode eighth-blocks for sub-character precision.
/// Returns a slice from the provided buffer.
/// U+2588 █ full, U+2589..U+258F partials (7/8..1/8).
pub fn buildBlockBar(buf: []u8, weight: f64, total_chars: usize) []const u8 {
const total_eighths: f64 = @as(f64, @floatFromInt(total_chars)) * 8.0;
const filled_eighths_f = weight * total_eighths;
const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths));
const full_blocks = filled_eighths / 8;
const partial = filled_eighths % 8;
var pos: usize = 0;
// Full blocks: U+2588 = E2 96 88
for (0..full_blocks) |_| {
buf[pos] = 0xE2;
buf[pos + 1] = 0x96;
buf[pos + 2] = 0x88;
pos += 3;
}
// Partial block
if (partial > 0) {
const code: u8 = 0x88 + @as(u8, @intCast(8 - partial));
buf[pos] = 0xE2;
buf[pos + 1] = 0x96;
buf[pos + 2] = code;
pos += 3;
}
// Empty spaces
const used = full_blocks + @as(usize, if (partial > 0) 1 else 0);
if (used < total_chars) {
@memset(buf[pos..][0 .. total_chars - used], ' ');
pos += total_chars - used;
}
return buf[0..pos];
}
/// Format a historical snapshot as "+1.5%" or "--" for display.
pub fn fmtHistoricalChange(buf: []u8, snap_count: usize, pct: f64) []const u8 {
if (snap_count == 0) return "--";
if (pct >= 0) {
return std.fmt.bufPrint(buf, "+{d:.1}%", .{pct}) catch "?";
} else {
return std.fmt.bufPrint(buf, "{d:.1}%", .{pct}) catch "?";
}
}
/// Format a candle as a fixed-width row: " YYYY-MM-DD 150.00 155.00 149.00 153.00 50,000,000"
pub fn fmtCandleRow(buf: []u8, candle: Candle) []const u8 {
var db: [10]u8 = undefined;
var vb: [32]u8 = undefined;
return std.fmt.bufPrint(buf, " {s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}", .{
candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmtIntCommas(&vb, candle.volume),
}) catch "";
}
/// Format a price change with sign: "+$3.50 (+2.04%)" or "-$3.50 (-2.04%)".
pub fn fmtPriceChange(buf: []u8, change: f64, pct: f64) []const u8 {
if (change >= 0) {
return std.fmt.bufPrint(buf, "+${d:.2} (+{d:.2}%)", .{ change, pct }) catch "?";
} else {
return std.fmt.bufPrint(buf, "-${d:.2} ({d:.2}%)", .{ -change, pct }) catch "?";
}
}
/// Interpolate color between two RGB values. t in [0.0, 1.0].
pub fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 {
return .{
@intFromFloat(@as(f64, @floatFromInt(a[0])) * (1.0 - t) + @as(f64, @floatFromInt(b[0])) * t),
@intFromFloat(@as(f64, @floatFromInt(a[1])) * (1.0 - t) + @as(f64, @floatFromInt(b[1])) * t),
@intFromFloat(@as(f64, @floatFromInt(a[2])) * (1.0 - t) + @as(f64, @floatFromInt(b[2])) * t),
};
}
// ── Braille chart ────────────────────────────────────────────
/// Braille dot patterns for the 2x4 matrix within each character cell.
/// Layout: [0][3] Bit mapping: dot0=0x01, dot3=0x08
/// [1][4] dot1=0x02, dot4=0x10
/// [2][5] dot2=0x04, dot5=0x20
/// [6][7] dot6=0x40, dot7=0x80
pub const braille_dots = [4][2]u8{
.{ 0x01, 0x08 }, // row 0 (top)
.{ 0x02, 0x10 }, // row 1
.{ 0x04, 0x20 }, // row 2
.{ 0x40, 0x80 }, // row 3 (bottom)
};
/// Comptime table of braille character UTF-8 encodings (U+2800..U+28FF).
/// Each braille codepoint is 3 bytes in UTF-8: 0xE2 0xA0+hi 0x80+lo.
pub const braille_utf8 = blk: {
var table: [256][3]u8 = undefined;
for (0..256) |i| {
const cp: u21 = 0x2800 + @as(u21, @intCast(i));
table[i] = .{
@as(u8, 0xE0 | @as(u8, @truncate(cp >> 12))),
@as(u8, 0x80 | @as(u8, @truncate((cp >> 6) & 0x3F))),
@as(u8, 0x80 | @as(u8, @truncate(cp & 0x3F))),
};
}
break :blk table;
};
/// Return a static-lifetime grapheme slice for a braille pattern byte.
pub fn brailleGlyph(pattern: u8) []const u8 {
return &braille_utf8[pattern];
}
/// Computed braille chart data, ready for rendering by CLI (ANSI) or TUI (vaxis).
pub const BrailleChart = struct {
/// Braille pattern bytes: patterns[row * n_cols + col]
patterns: []u8,
/// RGB color per data column
col_colors: [][3]u8,
n_cols: usize,
chart_height: usize,
max_label: [16]u8,
max_label_len: usize,
min_label: [16]u8,
min_label_len: usize,
/// Date of first candle in the chart data
start_date: Date,
/// Date of last candle in the chart data
end_date: Date,
pub fn maxLabel(self: *const BrailleChart) []const u8 {
return self.max_label[0..self.max_label_len];
}
pub fn minLabel(self: *const BrailleChart) []const u8 {
return self.min_label[0..self.min_label_len];
}
pub fn pattern(self: *const BrailleChart, row: usize, col: usize) u8 {
return self.patterns[row * self.n_cols + col];
}
/// Format a date as "MMM DD" or "MMM 'YY" depending on whether it's the same year as `ref_year`.
/// Returns the number of bytes written.
pub fn fmtShortDate(date: Date, buf: *[7]u8) []const u8 {
const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
const m = date.month();
const d = date.day();
const y = date.year();
const mon = if (m >= 1 and m <= 12) months[m - 1] else "???";
// Use "MMM DD 'YY" is too long (10 chars). Use "MMM 'YY" (7 chars) for year context,
// or "MMM DD" (6 chars) for day-level precision. We'll use "MMM DD" for compactness
// and add the year as a separate concern if dates span multiple years.
// Actually let's just use "YYYY-MM-DD" is too long. "Mon DD" is 6 chars.
buf[0] = mon[0];
buf[1] = mon[1];
buf[2] = mon[2];
buf[3] = ' ';
if (d >= 10) {
buf[4] = '0' + d / 10;
} else {
buf[4] = '0';
}
buf[5] = '0' + d % 10;
// If we want to show year when it differs, store in extra chars:
_ = y;
return buf[0..6];
}
pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void {
alloc.free(self.patterns);
alloc.free(self.col_colors);
}
};
/// Compute braille sparkline chart data from candle close prices.
/// Uses Unicode braille characters (U+2800..U+28FF) for 2-wide x 4-tall dot matrix per cell.
/// Each terminal row provides 4 sub-rows of resolution; each column maps to one data point.
///
/// Returns a BrailleChart with the pattern grid and per-column colors.
/// Caller must call deinit() when done (unless using an arena allocator).
pub fn computeBrailleChart(
alloc: std.mem.Allocator,
data: []const Candle,
chart_width: usize,
chart_height: usize,
positive_color: [3]u8,
negative_color: [3]u8,
) !BrailleChart {
if (data.len < 2) return error.InsufficientData;
const dot_rows: usize = chart_height * 4; // vertical dot resolution
// Find min/max close prices
var min_price: f64 = data[0].close;
var max_price: f64 = data[0].close;
for (data) |d| {
if (d.close < min_price) min_price = d.close;
if (d.close > max_price) max_price = d.close;
}
if (max_price == min_price) max_price = min_price + 1.0;
const price_range = max_price - min_price;
// Price labels
var result: BrailleChart = undefined;
const max_str = std.fmt.bufPrint(&result.max_label, "${d:.0}", .{max_price}) catch "";
result.max_label_len = max_str.len;
const min_str = std.fmt.bufPrint(&result.min_label, "${d:.0}", .{min_price}) catch "";
result.min_label_len = min_str.len;
const n_cols = @min(data.len, chart_width);
result.n_cols = n_cols;
result.chart_height = chart_height;
result.start_date = data[0].date;
result.end_date = data[data.len - 1].date;
// Map each data column to a dot-row position and color
const dot_y = try alloc.alloc(usize, n_cols);
defer alloc.free(dot_y);
result.col_colors = try alloc.alloc([3]u8, n_cols);
errdefer alloc.free(result.col_colors);
for (0..n_cols) |col| {
const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1));
const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1);
const close = data[data_idx].close;
const norm = (close - min_price) / price_range; // 0 = min, 1 = max
// Inverted: 0 = top dot row, dot_rows-1 = bottom
const y_f = (1.0 - norm) * @as(f64, @floatFromInt(dot_rows - 1));
dot_y[col] = @min(@as(usize, @intFromFloat(y_f)), dot_rows - 1);
// Color: gradient from negative (bottom) to positive (top)
result.col_colors[col] = lerpColor(negative_color, positive_color, norm);
}
// Build the braille pattern grid
result.patterns = try alloc.alloc(u8, chart_height * n_cols);
@memset(result.patterns, 0);
for (0..n_cols) |col| {
const target_y = dot_y[col];
// Fill from target_y down to the bottom
for (target_y..dot_rows) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
}
// Interpolate between this point and the next for smooth contour
if (col + 1 < n_cols) {
const y0 = dot_y[col];
const y1 = dot_y[col + 1];
const min_y = @min(y0, y1);
const max_y = @max(y0, y1);
for (min_y..max_y + 1) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
}
}
}
return result;
}
/// Write a braille chart to a writer with ANSI color escapes.
/// Used by the CLI for terminal output.
pub fn writeBrailleAnsi(
out: anytype,
chart: *const BrailleChart,
use_color: bool,
muted_color: [3]u8,
) !void {
var last_r: u8 = 0;
var last_g: u8 = 0;
var last_b: u8 = 0;
var color_active = false;
for (0..chart.chart_height) |row| {
try out.writeAll(" "); // 2 leading spaces
for (0..chart.n_cols) |col| {
const pat = chart.pattern(row, col);
if (use_color and pat != 0) {
const c = chart.col_colors[col];
// Only emit color escape if color changed
if (!color_active or c[0] != last_r or c[1] != last_g or c[2] != last_b) {
try out.print("\x1b[38;2;{d};{d};{d}m", .{ c[0], c[1], c[2] });
last_r = c[0];
last_g = c[1];
last_b = c[2];
color_active = true;
}
} else if (color_active and pat == 0) {
try out.writeAll("\x1b[0m");
color_active = false;
}
try out.writeAll(brailleGlyph(pat));
}
if (color_active) {
try out.writeAll("\x1b[0m");
color_active = false;
}
// Price label on first/last row
if (row == 0) {
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.print(" {s}", .{chart.maxLabel()});
if (use_color) try out.writeAll("\x1b[0m");
} else if (row == chart.chart_height - 1) {
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.print(" {s}", .{chart.minLabel()});
if (use_color) try out.writeAll("\x1b[0m");
}
try out.writeAll("\n");
}
// Date axis below chart
var start_buf: [7]u8 = undefined;
var end_buf: [7]u8 = undefined;
const start_label = BrailleChart.fmtShortDate(chart.start_date, &start_buf);
const end_label = BrailleChart.fmtShortDate(chart.end_date, &end_buf);
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.writeAll(" "); // match leading indent
try out.writeAll(start_label);
// Fill gap between start and end labels
const total_width = chart.n_cols;
if (total_width > start_label.len + end_label.len) {
const gap = total_width - start_label.len - end_label.len;
for (0..gap) |_| try out.writeAll(" ");
}
try out.writeAll(end_label);
if (use_color) try out.writeAll("\x1b[0m");
try out.writeAll("\n");
}
// ── ANSI color helpers (for CLI) ─────────────────────────────
/// Determine whether to use ANSI color output.
pub fn shouldUseColor(no_color_flag: bool) bool {
if (no_color_flag) return false;
if (std.posix.getenv("NO_COLOR")) |_| return false;
// Check if stdout is a TTY
return std.posix.isatty(std.fs.File.stdout().handle);
}
/// Write an ANSI 24-bit foreground color escape.
pub fn ansiSetFg(out: anytype, 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: anytype) !void {
try out.writeAll("\x1b[1m");
}
/// Write ANSI dim.
pub fn ansiDim(out: anytype) !void {
try out.writeAll("\x1b[2m");
}
/// Reset all ANSI attributes.
pub fn ansiReset(out: anytype) !void {
try out.writeAll("\x1b[0m");
}
// ── Tests ────────────────────────────────────────────────────
test "fmtMoney" {
var buf: [24]u8 = undefined;
try std.testing.expectEqualStrings("$0.00", fmtMoney(&buf, 0));
try std.testing.expectEqualStrings("$1.23", fmtMoney(&buf, 1.23));
try std.testing.expectEqualStrings("$1,234.56", fmtMoney(&buf, 1234.56));
try std.testing.expectEqualStrings("$1,234,567.89", fmtMoney(&buf, 1234567.89));
}
test "fmtMoney negative" {
// Negative amounts: the function uses abs(cents) so the sign is lost
// (implementation detail: no minus sign is produced, result is same as positive)
var buf: [24]u8 = undefined;
// Verify it doesn't crash on negative input
const result = fmtMoney(&buf, -1234.56);
try std.testing.expect(result.len > 0);
}
test "fmtMoney2" {
var buf: [24]u8 = undefined;
try std.testing.expectEqualStrings("$185.23", fmtMoney2(&buf, 185.23));
try std.testing.expectEqualStrings("$0.00", fmtMoney2(&buf, 0.0));
try std.testing.expectEqualStrings("$0.50", fmtMoney2(&buf, 0.5));
}
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);
}
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 "isCusipLike" {
try std.testing.expect(isCusipLike("02315N600")); // Vanguard Target 2035
try std.testing.expect(isCusipLike("02315N709")); // Vanguard Target 2040
try std.testing.expect(isCusipLike("459200101")); // IBM
try std.testing.expect(isCusipLike("06051XJ45")); // CD CUSIP
try std.testing.expect(!isCusipLike("AAPL")); // Too short
try std.testing.expect(!isCusipLike("ABCDEFGHI")); // No digits
try std.testing.expect(isCusipLike("NON40OR52")); // Looks cusip-like (has digits, 9 chars)
try std.testing.expect(!isCusipLike("12345")); // Too short
}
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({}, open_new, closed));
try std.testing.expect(!lotSortFn({}, closed, open_new));
// Among open lots: newest first
try std.testing.expect(lotSortFn({}, open_new, open_old));
try std.testing.expect(!lotSortFn({}, open_old, open_new));
}
test "lotMaturitySortFn" {
const with_maturity = Lot{
.symbol = "CD1",
.shares = 10000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 100,
.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 },
};
const agg = aggregateDripLots(&lots);
// The 2023 lot is >1 year old -> LT, the 2025 lots are ST
try std.testing.expect(!agg.lt.isEmpty());
try std.testing.expectEqual(@as(usize, 1), agg.lt.lot_count);
try std.testing.expectApproxEqAbs(@as(f64, 0.2), agg.lt.shares, 0.001);
try std.testing.expectEqual(@as(usize, 2), agg.st.lot_count);
try std.testing.expectApproxEqAbs(@as(f64, 0.8), agg.st.shares, 0.001);
// Avg cost
try std.testing.expectApproxEqAbs(@as(f64, 200.0), agg.lt.avgCost(), 0.01);
}
test "aggregateDripLots empty" {
const lots = [_]Lot{
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 210, .drip = false },
};
const agg = aggregateDripLots(&lots);
try std.testing.expect(agg.st.isEmpty());
try std.testing.expect(agg.lt.isEmpty());
}
test "lerpColor" {
// t=0 returns first color
const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0);
try std.testing.expectEqual(@as(u8, 0), c0[0]);
try std.testing.expectEqual(@as(u8, 0), c0[1]);
// t=1 returns second color
const c1 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 1.0);
try std.testing.expectEqual(@as(u8, 255), c1[0]);
// t=0.5 returns midpoint
const c_mid = lerpColor(.{ 0, 0, 0 }, .{ 200, 100, 50 }, 0.5);
try std.testing.expectEqual(@as(u8, 100), c_mid[0]);
try std.testing.expectEqual(@as(u8, 50), c_mid[1]);
try std.testing.expectEqual(@as(u8, 25), c_mid[2]);
}
test "brailleGlyph" {
// Pattern 0 = U+2800 (blank braille)
const blank = brailleGlyph(0);
try std.testing.expectEqual(@as(usize, 3), blank.len);
try std.testing.expectEqual(@as(u8, 0xE2), blank[0]);
try std.testing.expectEqual(@as(u8, 0xA0), blank[1]);
try std.testing.expectEqual(@as(u8, 0x80), blank[2]);
// Pattern 0xFF = U+28FF (full braille)
const full = brailleGlyph(0xFF);
try std.testing.expectEqual(@as(usize, 3), full.len);
try std.testing.expectEqual(@as(u8, 0xE2), full[0]);
try std.testing.expectEqual(@as(u8, 0xA3), full[1]);
try std.testing.expectEqual(@as(u8, 0xBF), full[2]);
}
test "BrailleChart.fmtShortDate" {
var buf: [7]u8 = undefined;
const jan15 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 1, 15), &buf);
try std.testing.expectEqualStrings("Jan 15", jan15);
const dec01 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 12, 1), &buf);
try std.testing.expectEqualStrings("Dec 01", dec01);
const jun09 = BrailleChart.fmtShortDate(Date.fromYmd(2026, 6, 9), &buf);
try std.testing.expectEqualStrings("Jun 09", jun09);
}
test "computeBrailleChart" {
const alloc = std.testing.allocator;
// Build synthetic candle data: 20 candles, prices rising from 100 to 119
var candles: [20]Candle = undefined;
for (0..20) |i| {
const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
try std.testing.expectEqual(@as(usize, 20), chart.n_cols);
try std.testing.expectEqual(@as(usize, 4), chart.chart_height);
try std.testing.expectEqual(@as(usize, 80), chart.patterns.len); // 4 * 20
try std.testing.expectEqual(@as(usize, 20), chart.col_colors.len);
// Max/min labels should contain price info
try std.testing.expect(chart.maxLabel().len > 0);
try std.testing.expect(chart.minLabel().len > 0);
}
test "computeBrailleChart insufficient data" {
const alloc = std.testing.allocator;
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
};
const result = computeBrailleChart(alloc, &candles, 10, 4, .{ 0, 0, 0 }, .{ 255, 255, 255 });
try std.testing.expectError(error.InsufficientData, result);
}
test "fmtContractLine" {
const alloc = std.testing.allocator;
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 = try fmtContractLine(alloc, "C ", contract);
defer alloc.free(line);
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" {
const alloc = std.testing.allocator;
const contract = OptionContract{
.strike = 200.0,
.contract_type = .put,
.expiration = Date.fromYmd(2024, 6, 21),
};
const line = try fmtContractLine(alloc, "P ", contract);
defer alloc.free(line);
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 "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"));
}