From a5822287655529b92f0ab57ae3de5564d5d19d1d Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 1 Mar 2026 11:22:37 -0800 Subject: [PATCH] ai: add tests to tui --- .pre-commit-config.yaml | 2 +- src/analytics/analysis.zig | 55 ++++- src/analytics/indicators.zig | 74 +++++++ src/analytics/risk.zig | 102 ++++++++- src/format.zig | 279 ++++++++++++++++++++++++ src/models/date.zig | 72 +++++++ src/models/earnings.zig | 39 ++++ src/models/etf_profile.zig | 17 ++ src/models/portfolio.zig | 82 +++++++ src/models/split.zig | 11 + src/tui.zig | 400 +++++++++++++++++++++-------------- 11 files changed, 973 insertions(+), 160 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ec5d585..c4ac86a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: - id: test name: Run zig build test entry: zig - args: ["build", "coverage", "-Dcoverage-threshold=30"] + args: ["build", "coverage", "-Dcoverage-threshold=40"] language: system types: [file] pass_filenames: false diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 0de59fe..6e696cf 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -2,7 +2,6 @@ /// /// Takes portfolio allocations (with market values) and classification metadata, /// produces breakdowns by asset class, sector, geographic region, account, and tax type. - const std = @import("std"); const Allocation = @import("risk.zig").Allocation; const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry; @@ -301,3 +300,57 @@ test "parseAccountsFile" { try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Fidelity Emil HSA")); try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent")); } + +test "taxTypeLabel" { + try std.testing.expectEqualStrings("Taxable", taxTypeLabel("taxable")); + try std.testing.expectEqualStrings("Roth (Post-Tax)", taxTypeLabel("roth")); + try std.testing.expectEqualStrings("Traditional (Pre-Tax)", taxTypeLabel("traditional")); + try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", taxTypeLabel("hsa")); + // Unknown type returns raw string + try std.testing.expectEqualStrings("custom_type", taxTypeLabel("custom_type")); +} + +test "mapToSortedBreakdown" { + const allocator = std.testing.allocator; + var map = std.StringHashMap(f64).init(allocator); + defer map.deinit(); + try map.put("Technology", 50_000); + try map.put("Healthcare", 30_000); + try map.put("Energy", 20_000); + + const total = 100_000.0; + const breakdown = try mapToSortedBreakdown(allocator, map, total); + defer allocator.free(breakdown); + + try std.testing.expectEqual(@as(usize, 3), breakdown.len); + // Should be sorted descending by value + try std.testing.expectEqualStrings("Technology", breakdown[0].label); + try std.testing.expectApproxEqAbs(@as(f64, 50_000), breakdown[0].value, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 0.5), breakdown[0].weight, 0.001); + try std.testing.expectEqualStrings("Healthcare", breakdown[1].label); + try std.testing.expectEqualStrings("Energy", breakdown[2].label); +} + +test "mapToSortedBreakdown empty" { + const allocator = std.testing.allocator; + var map = std.StringHashMap(f64).init(allocator); + defer map.deinit(); + const breakdown = try mapToSortedBreakdown(allocator, map, 100_000.0); + defer allocator.free(breakdown); + try std.testing.expectEqual(@as(usize, 0), breakdown.len); +} + +test "parseAccountsFile empty" { + const allocator = std.testing.allocator; + var am = try parseAccountsFile(allocator, ""); + defer am.deinit(); + try std.testing.expectEqual(@as(usize, 0), am.entries.len); +} + +test "parseAccountsFile missing fields" { + const allocator = std.testing.allocator; + // Line with only account but no tax_type -> skipped + var am = try parseAccountsFile(allocator, "account::Test Account\n# comment\n"); + defer am.deinit(); + try std.testing.expectEqual(@as(usize, 0), am.entries.len); +} diff --git a/src/analytics/indicators.zig b/src/analytics/indicators.zig index 07394bb..61a2328 100644 --- a/src/analytics/indicators.zig +++ b/src/analytics/indicators.zig @@ -134,3 +134,77 @@ test "rsi basic" { try std.testing.expect(result[13] == null); try std.testing.expect(result[14] != null); } + +test "bollingerBands basic" { + const alloc = std.testing.allocator; + const closes = [_]f64{ 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 }; + const bands = try bollingerBands(alloc, &closes, 5, 2.0); + defer alloc.free(bands); + // First 4 (indices 0-3) should be null (period=5, need indices 0..4) + try std.testing.expect(bands[0] == null); + try std.testing.expect(bands[3] == null); + // Index 4 onward should have values + try std.testing.expect(bands[4] != null); + const b4 = bands[4].?; + // SMA of [10,11,12,13,14] = 12.0 + try std.testing.expectApproxEqAbs(@as(f64, 12.0), b4.middle, 0.001); + // upper > middle > lower + try std.testing.expect(b4.upper > b4.middle); + try std.testing.expect(b4.middle > b4.lower); +} + +test "closePrices" { + const alloc = std.testing.allocator; + const candles = [_]Candle{ + .{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 2), .open = 100, .high = 105, .low = 99, .close = 102, .adj_close = 102, .volume = 1000 }, + .{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 3), .open = 102, .high = 107, .low = 101, .close = 105, .adj_close = 105, .volume = 2000 }, + }; + const prices = try closePrices(alloc, &candles); + defer alloc.free(prices); + try std.testing.expectEqual(@as(usize, 2), prices.len); + try std.testing.expectApproxEqAbs(@as(f64, 102), prices[0], 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 105), prices[1], 0.001); +} + +test "volumes" { + const alloc = std.testing.allocator; + const candles = [_]Candle{ + .{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 2), .open = 100, .high = 105, .low = 99, .close = 102, .adj_close = 102, .volume = 1500 }, + .{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 3), .open = 102, .high = 107, .low = 101, .close = 105, .adj_close = 105, .volume = 3000 }, + }; + const vols = try volumes(alloc, &candles); + defer alloc.free(vols); + try std.testing.expectEqual(@as(usize, 2), vols.len); + try std.testing.expectApproxEqAbs(@as(f64, 1500), vols[0], 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 3000), vols[1], 0.001); +} + +test "sma edge cases" { + // period=1: should equal the value itself + const closes = [_]f64{ 5, 10, 15 }; + try std.testing.expectApproxEqAbs(@as(f64, 5.0), sma(&closes, 0, 1).?, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 10.0), sma(&closes, 1, 1).?, 0.001); + // period > data length: always null + try std.testing.expect(sma(&closes, 2, 10) == null); +} + +test "rsi all up" { + const alloc = std.testing.allocator; + // Prices going up by 1 each day for 20 days + var closes: [20]f64 = undefined; + for (0..20) |i| closes[i] = 100.0 + @as(f64, @floatFromInt(i)); + const result = try rsi(alloc, &closes, 14); + defer alloc.free(result); + // RSI should be 100 (all gains, no losses) + try std.testing.expect(result[14] != null); + try std.testing.expectApproxEqAbs(@as(f64, 100.0), result[14].?, 0.001); +} + +test "rsi insufficient data" { + const alloc = std.testing.allocator; + const closes = [_]f64{ 1, 2, 3 }; + const result = try rsi(alloc, &closes, 14); + defer alloc.free(result); + // All should be null since len < period + 1 + for (result) |r| try std.testing.expect(r == null); +} diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index 966eece..800ee8d 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -349,8 +349,12 @@ test "risk metrics basic" { 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, + .open = price, + .high = price, + .low = price, + .close = price, + .adj_close = price, + .volume = 1000, }; } const metrics = computeRisk(&candles); @@ -398,3 +402,97 @@ test "max drawdown" { fn makeCandle(date: Date, price: f64) Candle { return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; } + +test "findPriceAtDate exact match" { + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2024, 1, 2), 100), + makeCandle(Date.fromYmd(2024, 1, 3), 101), + makeCandle(Date.fromYmd(2024, 1, 4), 102), + }; + const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 3)); + try std.testing.expect(price != null); + try std.testing.expectApproxEqAbs(@as(f64, 101), price.?, 0.01); +} + +test "findPriceAtDate snap backward" { + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2024, 1, 2), 100), + makeCandle(Date.fromYmd(2024, 1, 3), 101), + makeCandle(Date.fromYmd(2024, 1, 8), 105), // gap (weekend) + }; + // Target is Jan 5 (Saturday), should snap back to Jan 3 + const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 5)); + try std.testing.expect(price != null); + try std.testing.expectApproxEqAbs(@as(f64, 101), price.?, 0.01); +} + +test "findPriceAtDate too far back" { + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2024, 1, 15), 100), + makeCandle(Date.fromYmd(2024, 1, 16), 101), + }; + // Target is Jan 2, closest is Jan 15 (13 days gap > 7 days) + const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 2)); + try std.testing.expect(price == null); +} + +test "findPriceAtDate empty" { + const candles: []const Candle = &.{}; + try std.testing.expect(findPriceAtDate(candles, Date.fromYmd(2024, 1, 1)) == null); +} + +test "findPriceAtDate before all candles" { + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2024, 6, 1), 150), + makeCandle(Date.fromYmd(2024, 6, 2), 151), + }; + // Target is way before all candles + try std.testing.expect(findPriceAtDate(&candles, Date.fromYmd(2020, 1, 1)) == null); +} + +test "HistoricalSnapshot change and changePct" { + const snap = HistoricalSnapshot{ + .period = .@"1Y", + .target_date = Date.fromYmd(2023, 1, 1), + .historical_value = 100_000, + .current_value = 120_000, + .position_count = 5, + .total_positions = 5, + }; + try std.testing.expectApproxEqAbs(@as(f64, 20_000), snap.change(), 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 20.0), snap.changePct(), 0.01); + // Zero historical value -> changePct returns 0 + const zero = HistoricalSnapshot{ + .period = .@"1M", + .target_date = Date.fromYmd(2024, 1, 1), + .historical_value = 0, + .current_value = 100, + .position_count = 0, + .total_positions = 0, + }; + try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero.changePct(), 0.001); +} + +test "HistoricalPeriod label and targetDate" { + try std.testing.expectEqualStrings("1M", HistoricalPeriod.@"1M".label()); + try std.testing.expectEqualStrings("3M", HistoricalPeriod.@"3M".label()); + try std.testing.expectEqualStrings("1Y", HistoricalPeriod.@"1Y".label()); + try std.testing.expectEqualStrings("10Y", HistoricalPeriod.@"10Y".label()); + // targetDate: 1Y from 2025-06-15 -> 2024-06-15 + const today = Date.fromYmd(2025, 6, 15); + const one_year = HistoricalPeriod.@"1Y".targetDate(today); + try std.testing.expectEqual(@as(i16, 2024), one_year.year()); + try std.testing.expectEqual(@as(u8, 6), one_year.month()); + // targetDate: 1M from 2025-03-15 -> 2025-02-15 + const one_month = HistoricalPeriod.@"1M".targetDate(Date.fromYmd(2025, 3, 15)); + try std.testing.expectEqual(@as(u8, 2), one_month.month()); +} + +test "computeRisk insufficient data" { + var candles: [10]Candle = undefined; + for (0..10) |i| { + candles[i] = makeCandle(Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), 100.0 + @as(f64, @floatFromInt(i))); + } + // Less than 21 candles -> returns null + try std.testing.expect(computeRisk(&candles) == null); +} diff --git a/src/format.zig b/src/format.zig index 265878a..ba4d228 100644 --- a/src/format.zig +++ b/src/format.zig @@ -674,6 +674,22 @@ test "fmtMoney" { 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)); @@ -681,3 +697,266 @@ test "fmtIntCommas" { 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 "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 "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); +} diff --git a/src/models/date.zig b/src/models/date.zig index a930647..a806e10 100644 --- a/src/models/date.zig +++ b/src/models/date.zig @@ -212,3 +212,75 @@ test "lastDayOfPriorMonth" { try std.testing.expectEqual(@as(u8, 2), d4.month()); try std.testing.expectEqual(@as(u8, 28), d4.day()); } + +test "dayOfWeek" { + // 1970-01-01 was a Thursday (3 in 0=Mon scheme) + try std.testing.expectEqual(@as(u8, 3), Date.fromYmd(1970, 1, 1).dayOfWeek()); + // 2024-01-01 (Monday) + try std.testing.expectEqual(@as(u8, 0), Date.fromYmd(2024, 1, 1).dayOfWeek()); + // 2024-01-19 (Friday) + try std.testing.expectEqual(@as(u8, 4), Date.fromYmd(2024, 1, 19).dayOfWeek()); + // 2024-01-20 (Saturday) + try std.testing.expectEqual(@as(u8, 5), Date.fromYmd(2024, 1, 20).dayOfWeek()); + // 2024-01-21 (Sunday) + try std.testing.expectEqual(@as(u8, 6), Date.fromYmd(2024, 1, 21).dayOfWeek()); +} + +test "eql and lessThan" { + const a = Date.fromYmd(2024, 6, 15); + const b = Date.fromYmd(2024, 6, 15); + const c = Date.fromYmd(2024, 6, 16); + try std.testing.expect(a.eql(b)); + try std.testing.expect(!a.eql(c)); + try std.testing.expect(a.lessThan(c)); + try std.testing.expect(!c.lessThan(a)); + try std.testing.expect(!a.lessThan(b)); +} + +test "addDays" { + const d = Date.fromYmd(2024, 1, 30); + const next = d.addDays(2); + try std.testing.expectEqual(@as(u8, 1), next.day()); + try std.testing.expectEqual(@as(u8, 2), next.month()); // Feb 1 + // Negative days + const prev = d.addDays(-30); + try std.testing.expectEqual(@as(u8, 31), prev.day()); + try std.testing.expectEqual(@as(u8, 12), prev.month()); // Dec 31 + try std.testing.expectEqual(@as(i16, 2023), prev.year()); +} + +test "subtractMonths" { + // Normal case + const d1 = Date.fromYmd(2024, 6, 15).subtractMonths(3); + try std.testing.expectEqual(@as(u8, 3), d1.month()); + try std.testing.expectEqual(@as(u8, 15), d1.day()); + // Cross year boundary: Jan - 1 = Dec prior year + const d2 = Date.fromYmd(2024, 1, 15).subtractMonths(1); + try std.testing.expectEqual(@as(u8, 12), d2.month()); + try std.testing.expectEqual(@as(i16, 2023), d2.year()); + // Day clamping: Mar 31 - 1M = Feb 29 (leap year 2024) + const d3 = Date.fromYmd(2024, 3, 31).subtractMonths(1); + try std.testing.expectEqual(@as(u8, 2), d3.month()); + try std.testing.expectEqual(@as(u8, 29), d3.day()); + // Day clamping: Mar 31 - 1M = Feb 28 (non-leap year 2025) + const d4 = Date.fromYmd(2025, 3, 31).subtractMonths(1); + try std.testing.expectEqual(@as(u8, 2), d4.month()); + try std.testing.expectEqual(@as(u8, 28), d4.day()); +} + +test "yearsBetween" { + const a = Date.fromYmd(2024, 1, 1); + const b = Date.fromYmd(2025, 1, 1); + try std.testing.expectApproxEqAbs(@as(f64, 1.0), Date.yearsBetween(a, b), 0.01); + // Half year + const c = Date.fromYmd(2024, 7, 1); + try std.testing.expectApproxEqAbs(@as(f64, 0.5), Date.yearsBetween(a, c), 0.02); + // Same date + try std.testing.expectApproxEqAbs(@as(f64, 0.0), Date.yearsBetween(a, a), 0.001); +} + +test "parse error cases" { + try std.testing.expectError(error.InvalidDateFormat, Date.parse("not-a-date")); + try std.testing.expectError(error.InvalidDateFormat, Date.parse("20240115")); // no dashes + try std.testing.expectError(error.InvalidDateFormat, Date.parse("2024/01/15")); // wrong separator +} diff --git a/src/models/earnings.zig b/src/models/earnings.zig index 4e35a7d..efe6657 100644 --- a/src/models/earnings.zig +++ b/src/models/earnings.zig @@ -49,3 +49,42 @@ pub const EarningsEvent = struct { return ((act - est) / @abs(est)) * 100.0; } }; + +const std = @import("std"); + +test "isFuture" { + const future = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 20000 }, .estimate = 1.5 }; + try std.testing.expect(future.isFuture()); + const past = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 19000 }, .actual = 1.6, .estimate = 1.5 }; + try std.testing.expect(!past.isFuture()); +} + +test "surpriseAmount" { + // With surprise field set directly + const with_surprise = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 19000 }, .surprise = 0.15 }; + try std.testing.expectApproxEqAbs(@as(f64, 0.15), with_surprise.surpriseAmount().?, 0.001); + // With actual and estimate (computed) + const computed = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 19000 }, .actual = 1.65, .estimate = 1.50 }; + try std.testing.expectApproxEqAbs(@as(f64, 0.15), computed.surpriseAmount().?, 0.001); + // Missing actual -> null + const no_actual = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 19000 }, .estimate = 1.50 }; + try std.testing.expect(no_actual.surpriseAmount() == null); + // Missing estimate -> null + const no_estimate = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 19000 }, .actual = 1.65 }; + try std.testing.expect(no_estimate.surpriseAmount() == null); +} + +test "surprisePct" { + // With surprise_percent set directly + const with_pct = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 19000 }, .surprise_percent = 10.0 }; + try std.testing.expectApproxEqAbs(@as(f64, 10.0), with_pct.surprisePct().?, 0.001); + // Computed: actual=1.65, estimate=1.50 -> (0.15/1.50)*100 = 10% + const computed = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 19000 }, .actual = 1.65, .estimate = 1.50 }; + try std.testing.expectApproxEqAbs(@as(f64, 10.0), computed.surprisePct().?, 0.001); + // Estimate = 0 -> null (division by zero guard) + const zero_est = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 19000 }, .actual = 1.0, .estimate = 0.0 }; + try std.testing.expect(zero_est.surprisePct() == null); + // Negative surprise: actual < estimate + const miss = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 19000 }, .actual = 1.35, .estimate = 1.50 }; + try std.testing.expect(miss.surprisePct().? < 0); +} diff --git a/src/models/etf_profile.zig b/src/models/etf_profile.zig index f0fdf3b..1305e6d 100644 --- a/src/models/etf_profile.zig +++ b/src/models/etf_profile.zig @@ -51,3 +51,20 @@ pub const EtfProfile = struct { self.total_holdings != null; } }; + +const std = @import("std"); + +test "isEtf" { + // Empty profile -> not an ETF + const empty = EtfProfile{ .symbol = "AAPL" }; + try std.testing.expect(!empty.isEtf()); + // Has expense_ratio -> is ETF + const with_er = EtfProfile{ .symbol = "VTI", .expense_ratio = 0.0003 }; + try std.testing.expect(with_er.isEtf()); + // Has net_assets -> is ETF + const with_na = EtfProfile{ .symbol = "SPY", .net_assets = 500_000_000_000 }; + try std.testing.expect(with_na.isEtf()); + // Has total_holdings -> is ETF + const with_th = EtfProfile{ .symbol = "QQQ", .total_holdings = 100 }; + try std.testing.expect(with_th.isEtf()); +} diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 933c2f4..af57c1e 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -395,3 +395,85 @@ test "portfolio positions" { try std.testing.expectEqual(@as(u32, 1), aapl.?.closed_lots); try std.testing.expectApproxEqAbs(@as(f64, 75.0), aapl.?.realized_pnl, 0.01); // 3 * (155-130) } + +test "LotType label and fromString" { + try std.testing.expectEqualStrings("Stock", LotType.stock.label()); + try std.testing.expectEqualStrings("Option", LotType.option.label()); + try std.testing.expectEqualStrings("CD", LotType.cd.label()); + try std.testing.expectEqualStrings("Cash", LotType.cash.label()); + try std.testing.expectEqualStrings("Illiquid", LotType.illiquid.label()); + try std.testing.expectEqualStrings("Watch", LotType.watch.label()); + + try std.testing.expectEqual(LotType.option, LotType.fromString("option")); + try std.testing.expectEqual(LotType.cd, LotType.fromString("cd")); + try std.testing.expectEqual(LotType.cash, LotType.fromString("cash")); + try std.testing.expectEqual(LotType.illiquid, LotType.fromString("illiquid")); + try std.testing.expectEqual(LotType.watch, LotType.fromString("watch")); + try std.testing.expectEqual(LotType.stock, LotType.fromString("unknown")); + try std.testing.expectEqual(LotType.stock, LotType.fromString("")); +} + +test "Lot.priceSymbol" { + const with_ticker = Lot{ .symbol = "9128283H2", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .ticker = "VTTHX" }; + try std.testing.expectEqualStrings("VTTHX", with_ticker.priceSymbol()); + const without_ticker = Lot{ .symbol = "AAPL", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 }; + try std.testing.expectEqualStrings("AAPL", without_ticker.priceSymbol()); +} + +test "Lot.returnPct" { + // Open lot: uses current_price param + const open_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 }; + try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150), 0.001); + // Closed lot: uses close_price, ignores current_price + const closed_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 120 }; + try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999), 0.001); + // Zero open_price: returns 0 + const zero_lot = Lot{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0 }; + try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100), 0.001); +} + +test "Portfolio totals" { + var lots = [_]Lot{ + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .lot_type = .stock }, + .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140, .lot_type = .stock, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 160 }, + .{ .symbol = "Savings", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .cash }, + .{ .symbol = "CD-1Y", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .cd }, + .{ .symbol = "House", .shares = 500000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 0, .lot_type = .illiquid }, + .{ .symbol = "SPY_CALL", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.50, .lot_type = .option }, + .{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .watch }, + }; + const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; + + // totalCostBasis: only open stock lots -> 10 * 150 = 1500 + try std.testing.expectApproxEqAbs(@as(f64, 1500.0), portfolio.totalCostBasis(), 0.01); + // totalRealizedPnl: closed stock lots -> 5 * (160-140) = 100 + try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedPnl(), 0.01); + // totalCash + try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(), 0.01); + // totalIlliquid + try std.testing.expectApproxEqAbs(@as(f64, 500000.0), portfolio.totalIlliquid(), 0.01); + // totalCdFaceValue + try std.testing.expectApproxEqAbs(@as(f64, 10000.0), portfolio.totalCdFaceValue(), 0.01); + // totalOptionCost: |2| * 5.50 = 11 + try std.testing.expectApproxEqAbs(@as(f64, 11.0), portfolio.totalOptionCost(), 0.01); + // hasType + try std.testing.expect(portfolio.hasType(.stock)); + try std.testing.expect(portfolio.hasType(.cash)); + try std.testing.expect(portfolio.hasType(.cd)); + try std.testing.expect(portfolio.hasType(.illiquid)); + try std.testing.expect(portfolio.hasType(.option)); + try std.testing.expect(portfolio.hasType(.watch)); +} + +test "Portfolio watchSymbols" { + const allocator = std.testing.allocator; + var lots = [_]Lot{ + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 }, + .{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .watch }, + .{ .symbol = "NVDA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .watch }, + }; + const portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; + const watch = try portfolio.watchSymbols(allocator); + defer allocator.free(watch); + try std.testing.expectEqual(@as(usize, 2), watch.len); +} diff --git a/src/models/split.zig b/src/models/split.zig index bbcf7a3..5de632a 100644 --- a/src/models/split.zig +++ b/src/models/split.zig @@ -13,3 +13,14 @@ pub const Split = struct { return self.numerator / self.denominator; } }; + +const std = @import("std"); + +test "split ratio" { + const forward = Split{ .date = Date{ .days = 19000 }, .numerator = 4, .denominator = 1 }; + try std.testing.expectApproxEqAbs(@as(f64, 4.0), forward.ratio(), 0.001); + const reverse = Split{ .date = Date{ .days = 19000 }, .numerator = 1, .denominator = 10 }; + try std.testing.expectApproxEqAbs(@as(f64, 0.1), reverse.ratio(), 0.001); + const two_for_one = Split{ .date = Date{ .days = 19000 }, .numerator = 2, .denominator = 1 }; + try std.testing.expectApproxEqAbs(@as(f64, 2.0), two_for_one.ratio(), 0.001); +} diff --git a/src/tui.zig b/src/tui.zig index c94eab2..cf36368 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -3315,82 +3315,7 @@ const App = struct { // ── Earnings tab ───────────────────────────────────────────── fn buildEarningsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { - const th = self.theme; - var lines: std.ArrayList(StyledLine) = .empty; - - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - - if (self.symbol.len == 0) { - try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); - return lines.toOwnedSlice(arena); - } - if (self.earnings_disabled) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{self.symbol}), .style = th.mutedStyle() }); - return lines.toOwnedSlice(arena); - } - - var earn_ago_buf: [16]u8 = undefined; - const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, self.earnings_timestamp); - if (earn_ago.len > 0) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ self.symbol, earn_ago }), .style = th.headerStyle() }); - } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s}", .{self.symbol}), .style = th.headerStyle() }); - } - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - - const ev = self.earnings_data orelse { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{self.symbol}), .style = th.mutedStyle() }); - return lines.toOwnedSlice(arena); - }; - if (ev.len == 0) { - try lines.append(arena, .{ .text = " No earnings events found.", .style = th.mutedStyle() }); - return lines.toOwnedSlice(arena); - } - - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{ - "Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %", - }), .style = th.mutedStyle() }); - - for (ev) |e| { - 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 = if (e.surpriseAmount()) |s| - (if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?") - else - @as([]const u8, "--"); - - var surp_pct_buf: [12]u8 = undefined; - const surp_pct_str = if (e.surprisePct()) |sp| - (if (sp >= 0) std.fmt.bufPrint(&surp_pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&surp_pct_buf, "{d:.1}%", .{sp}) catch "?") - else - @as([]const u8, "--"); - - const text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{ - date_str, q_str, est_str, act_str, surp_str, surp_pct_str, - }); - - // Color by surprise - const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true; - const row_style = if (e.isFuture()) th.mutedStyle() else if (surprise_positive) th.positiveStyle() else th.negativeStyle(); - - try lines.append(arena, .{ .text = text, .style = row_style }); - } - - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {d} earnings event(s)", .{ev.len}), .style = th.mutedStyle() }); - - return lines.toOwnedSlice(arena); + return renderEarningsLines(arena, self.theme, self.symbol, self.earnings_disabled, self.earnings_data, self.earnings_timestamp); } // ── Analysis tab ──────────────────────────────────────────── @@ -3470,86 +3395,7 @@ const App = struct { } fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { - const th = self.theme; - var lines: std.ArrayList(StyledLine) = .empty; - - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = " Portfolio Analysis", .style = th.headerStyle() }); - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - - const result = self.analysis_result orelse { - try lines.append(arena, .{ .text = " No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() }); - try lines.append(arena, .{ .text = " Run: zfin enrich > metadata.srf", .style = th.mutedStyle() }); - return lines.toOwnedSlice(arena); - }; - - // Helper: render a breakdown section with horizontal bar chart - const bar_width: usize = 30; - const label_width: usize = 24; // wide enough for "International Developed" - - // Asset Class breakdown - try lines.append(arena, .{ .text = " Asset Class", .style = th.headerStyle() }); - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - for (result.asset_class) |item| { - const text = try fmtBreakdownLine(arena, item, bar_width, label_width); - try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); - } - - // Sector breakdown - if (result.sector.len > 0) { - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = " Sector (Equities)", .style = th.headerStyle() }); - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - for (result.sector) |item| { - const text = try fmtBreakdownLine(arena, item, bar_width, label_width); - try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); - } - } - - // Geographic breakdown - if (result.geo.len > 0) { - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = " Geographic", .style = th.headerStyle() }); - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - for (result.geo) |item| { - const text = try fmtBreakdownLine(arena, item, bar_width, label_width); - try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); - } - } - - // Account breakdown - if (result.account.len > 0) { - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = " By Account", .style = th.headerStyle() }); - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - for (result.account) |item| { - const text = try fmtBreakdownLine(arena, item, bar_width, label_width); - try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); - } - } - - // Tax type breakdown - if (result.tax_type.len > 0) { - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = " By Tax Type", .style = th.headerStyle() }); - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - for (result.tax_type) |item| { - const text = try fmtBreakdownLine(arena, item, bar_width, label_width); - try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); - } - } - - // Unclassified positions - if (result.unclassified.len > 0) { - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = " Unclassified (not in metadata.srf)", .style = th.warningStyle() }); - for (result.unclassified) |sym| { - const text = try std.fmt.allocPrint(arena, " {s}", .{sym}); - try lines.append(arena, .{ .text = text, .style = th.mutedStyle() }); - } - } - - return lines.toOwnedSlice(arena); + return renderAnalysisLines(arena, self.theme, self.analysis_result); } fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 { @@ -4033,6 +3879,146 @@ fn launchEditor(allocator: std.mem.Allocator, portfolio_path: ?[]const u8, watch _ = child.wait() catch {}; } +// ── Standalone render functions (testable without App) ──────────────── + +/// Render earnings tab content. Pure function — no App dependency. +fn renderEarningsLines( + arena: std.mem.Allocator, + th: theme_mod.Theme, + symbol: []const u8, + earnings_disabled: bool, + earnings_data: ?[]const zfin.EarningsEvent, + earnings_timestamp: i64, +) ![]const StyledLine { + var lines: std.ArrayList(StyledLine) = .empty; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + if (symbol.len == 0) { + try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + if (earnings_disabled) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{symbol}), .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + + var earn_ago_buf: [16]u8 = undefined; + const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, earnings_timestamp); + if (earn_ago.len > 0) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ symbol, earn_ago }), .style = th.headerStyle() }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s}", .{symbol}), .style = th.headerStyle() }); + } + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + const ev = earnings_data orelse { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{symbol}), .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + }; + if (ev.len == 0) { + try lines.append(arena, .{ .text = " No earnings events found.", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{ + "Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %", + }), .style = th.mutedStyle() }); + + for (ev) |e| { + 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 = if (e.surpriseAmount()) |s| + (if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?") + else + @as([]const u8, "--"); + + var surp_pct_buf: [12]u8 = undefined; + const surp_pct_str = if (e.surprisePct()) |sp| + (if (sp >= 0) std.fmt.bufPrint(&surp_pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&surp_pct_buf, "{d:.1}%", .{sp}) catch "?") + else + @as([]const u8, "--"); + + const text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{ + date_str, q_str, est_str, act_str, surp_str, surp_pct_str, + }); + + // Color by surprise + const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true; + const row_style = if (e.isFuture()) th.mutedStyle() else if (surprise_positive) th.positiveStyle() else th.negativeStyle(); + + try lines.append(arena, .{ .text = text, .style = row_style }); + } + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {d} earnings event(s)", .{ev.len}), .style = th.mutedStyle() }); + + return lines.toOwnedSlice(arena); +} + +/// Render analysis tab content. Pure function — no App dependency. +fn renderAnalysisLines( + arena: std.mem.Allocator, + th: theme_mod.Theme, + analysis_result: ?zfin.analysis.AnalysisResult, +) ![]const StyledLine { + var lines: std.ArrayList(StyledLine) = .empty; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Portfolio Analysis", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + const result = analysis_result orelse { + try lines.append(arena, .{ .text = " No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = " Run: zfin enrich > metadata.srf", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + }; + + const bar_width: usize = 30; + const label_width: usize = 24; + + const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{ + .{ .items = result.asset_class, .title = " Asset Class" }, + .{ .items = result.sector, .title = " Sector (Equities)" }, + .{ .items = result.geo, .title = " Geographic" }, + .{ .items = result.account, .title = " By Account" }, + .{ .items = result.tax_type, .title = " By Tax Type" }, + }; + + for (sections, 0..) |sec, si| { + if (si > 0 and sec.items.len == 0) continue; + if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = sec.title, .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + for (sec.items) |item| { + const text = try App.fmtBreakdownLine(arena, item, bar_width, label_width); + try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); + } + } + + if (result.unclassified.len > 0) { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Unclassified (not in metadata.srf)", .style = th.warningStyle() }); + for (result.unclassified) |sym| { + const text = try std.fmt.allocPrint(arena, " {s}", .{sym}); + try lines.append(arena, .{ .text = text, .style = th.mutedStyle() }); + } + } + + return lines.toOwnedSlice(arena); +} + // ── Tests ───────────────────────────────────────────────────────────── const testing = std.testing; @@ -4145,3 +4131,105 @@ test "Tab label" { try testing.expectEqualStrings(" 1:Portfolio ", Tab.portfolio.label()); try testing.expectEqualStrings(" 6:Analysis ", Tab.analysis.label()); } + +test "renderEarningsLines with earnings data" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const th = theme_mod.default_theme; + + const events = [_]zfin.EarningsEvent{.{ + .symbol = "AAPL", + .date = try zfin.Date.parse("2025-01-15"), + .quarter = 4, + .estimate = 1.50, + .actual = 1.65, + }}; + const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0); + // blank + header + blank + col_header + data_row + blank + count = 7 + try testing.expectEqual(@as(usize, 7), lines.len); + try testing.expect(std.mem.indexOf(u8, lines[1].text, "AAPL") != null); + try testing.expect(std.mem.indexOf(u8, lines[3].text, "EPS Est") != null); + // Data row should contain the date + try testing.expect(std.mem.indexOf(u8, lines[4].text, "2025-01-15") != null); +} + +test "renderEarningsLines no symbol" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const th = theme_mod.default_theme; + + const lines = try renderEarningsLines(arena, th, "", false, null, 0); + try testing.expectEqual(@as(usize, 2), lines.len); + try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null); +} + +test "renderEarningsLines disabled" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const th = theme_mod.default_theme; + + const lines = try renderEarningsLines(arena, th, "VTI", true, null, 0); + try testing.expectEqual(@as(usize, 2), lines.len); + try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null); +} + +test "renderEarningsLines no data" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const th = theme_mod.default_theme; + + const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0); + try testing.expectEqual(@as(usize, 4), lines.len); + try testing.expect(std.mem.indexOf(u8, lines[3].text, "No data") != null); +} + +test "renderAnalysisLines with data" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const th = theme_mod.default_theme; + + var asset_class = [_]zfin.analysis.BreakdownItem{ + .{ .label = "US Stock", .weight = 0.60, .value = 120000 }, + .{ .label = "Int'l Stock", .weight = 0.40, .value = 80000 }, + }; + const result = zfin.analysis.AnalysisResult{ + .asset_class = &asset_class, + .sector = &.{}, + .geo = &.{}, + .account = &.{}, + .tax_type = &.{}, + .unclassified = &.{}, + .total_value = 200000, + }; + const lines = try renderAnalysisLines(arena, th, result); + // Should have header section + asset class items + try testing.expect(lines.len >= 5); + // Find "Portfolio Analysis" header + var found_header = false; + for (lines) |l| { + if (std.mem.indexOf(u8, l.text, "Portfolio Analysis") != null) found_header = true; + } + try testing.expect(found_header); + // Find asset class data + var found_us = false; + for (lines) |l| { + if (std.mem.indexOf(u8, l.text, "US Stock") != null) found_us = true; + } + try testing.expect(found_us); +} + +test "renderAnalysisLines no data" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const th = theme_mod.default_theme; + + const lines = try renderAnalysisLines(arena, th, null); + try testing.expectEqual(@as(usize, 5), lines.len); + try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null); +}