//! Shared formatting utilities used by both CLI and TUI. //! //! Number formatting (fmtMoneyAbs, 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 = fmtMoneyAbs(&money_buf, amount); const w = cash_acct_width; // " {name: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 = fmtMoneyAbs(&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 const fmtIlliquidSep = fmtCashSep; /// Format the illiquid total row. pub const fmtIlliquidTotal = fmtCashTotal; // ── Number formatters ──────────────────────────────────────── /// Format a dollar amount with commas and 2 decimals: $1,234.56 /// Always returns the absolute value — callers handle sign display. pub fn fmtMoneyAbs(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 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 < std.time.s_per_hour) { return std.fmt.bufPrint(buf, "{d}m ago", .{@as(u64, @intCast(@divFloor(delta, 60)))}) 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 = .{' '} ** 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, std.time.s_per_day)); 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(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). 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; } // ── 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 = fmtMoneyAbs(&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" for the braille chart x-axis. /// The year context is already visible in the surrounding CLI/TUI interface. /// 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 mon = if (m >= 1 and m <= 12) months[m - 1] else "???"; 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; 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 "fmtMoneyAbs" { var buf: [24]u8 = undefined; try std.testing.expectEqualStrings("$0.00", fmtMoneyAbs(&buf, 0)); try std.testing.expectEqualStrings("$1.23", fmtMoneyAbs(&buf, 1.23)); try std.testing.expectEqualStrings("$1,234.56", fmtMoneyAbs(&buf, 1234.56)); try std.testing.expectEqualStrings("$1,234,567.89", fmtMoneyAbs(&buf, 1234567.89)); } test "fmtMoneyAbs negative" { // Returns absolute value — callers handle sign display. var buf: [24]u8 = undefined; const result = fmtMoneyAbs(&buf, -1234.56); try std.testing.expectEqualStrings("$1,234.56", result); } 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" { 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 "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")); }