ai: add tests to tui

This commit is contained in:
Emil Lerch 2026-03-01 11:22:37 -08:00
parent ce0238246f
commit a582228765
Signed by: lobo
GPG key ID: A7B62D657EF764F8
11 changed files with 973 additions and 160 deletions

View file

@ -29,7 +29,7 @@ repos:
- id: test - id: test
name: Run zig build test name: Run zig build test
entry: zig entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=30"] args: ["build", "coverage", "-Dcoverage-threshold=40"]
language: system language: system
types: [file] types: [file]
pass_filenames: false pass_filenames: false

View file

@ -2,7 +2,6 @@
/// ///
/// Takes portfolio allocations (with market values) and classification metadata, /// Takes portfolio allocations (with market values) and classification metadata,
/// produces breakdowns by asset class, sector, geographic region, account, and tax type. /// produces breakdowns by asset class, sector, geographic region, account, and tax type.
const std = @import("std"); const std = @import("std");
const Allocation = @import("risk.zig").Allocation; const Allocation = @import("risk.zig").Allocation;
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry; 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("HSA (Triple Tax-Free)", am.taxTypeFor("Fidelity Emil HSA"));
try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent")); 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);
}

View file

@ -134,3 +134,77 @@ test "rsi basic" {
try std.testing.expect(result[13] == null); try std.testing.expect(result[13] == null);
try std.testing.expect(result[14] != 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);
}

View file

@ -349,8 +349,12 @@ test "risk metrics basic" {
const price: f64 = 100.0 + @as(f64, @floatFromInt(i)); const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
candles[i] = .{ candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price, .high = price, .low = price, .open = price,
.close = price, .adj_close = price, .volume = 1000, .high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
}; };
} }
const metrics = computeRisk(&candles); const metrics = computeRisk(&candles);
@ -398,3 +402,97 @@ test "max drawdown" {
fn makeCandle(date: Date, price: f64) Candle { fn makeCandle(date: Date, price: f64) Candle {
return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; 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);
}

View file

@ -674,6 +674,22 @@ test "fmtMoney" {
try std.testing.expectEqualStrings("$1,234,567.89", fmtMoney(&buf, 1234567.89)); 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" { test "fmtIntCommas" {
var buf: [32]u8 = undefined; var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("0", fmtIntCommas(&buf, 0)); 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,000", fmtIntCommas(&buf, 1000));
try std.testing.expectEqualStrings("1,234,567", fmtIntCommas(&buf, 1234567)); 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);
}

View file

@ -212,3 +212,75 @@ test "lastDayOfPriorMonth" {
try std.testing.expectEqual(@as(u8, 2), d4.month()); try std.testing.expectEqual(@as(u8, 2), d4.month());
try std.testing.expectEqual(@as(u8, 28), d4.day()); 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
}

View file

@ -49,3 +49,42 @@ pub const EarningsEvent = struct {
return ((act - est) / @abs(est)) * 100.0; 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);
}

View file

@ -51,3 +51,20 @@ pub const EtfProfile = struct {
self.total_holdings != null; 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());
}

View file

@ -395,3 +395,85 @@ test "portfolio positions" {
try std.testing.expectEqual(@as(u32, 1), aapl.?.closed_lots); 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) 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);
}

View file

@ -13,3 +13,14 @@ pub const Split = struct {
return self.numerator / self.denominator; 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);
}

View file

@ -3315,82 +3315,7 @@ const App = struct {
// Earnings tab // Earnings tab
fn buildEarningsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { fn buildEarningsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme; return renderEarningsLines(arena, self.theme, self.symbol, self.earnings_disabled, self.earnings_data, self.earnings_timestamp);
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);
} }
// Analysis tab // Analysis tab
@ -3470,86 +3395,7 @@ const App = struct {
} }
fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme; return renderAnalysisLines(arena, self.theme, self.analysis_result);
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 <portfolio.srf> > 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);
} }
fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 { 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 {}; _ = 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 <portfolio.srf> > 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 // Tests
const testing = std.testing; const testing = std.testing;
@ -4145,3 +4131,105 @@ test "Tab label" {
try testing.expectEqualStrings(" 1:Portfolio ", Tab.portfolio.label()); try testing.expectEqualStrings(" 1:Portfolio ", Tab.portfolio.label());
try testing.expectEqualStrings(" 6:Analysis ", Tab.analysis.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);
}