ai: add tests to tui
This commit is contained in:
parent
ce0238246f
commit
a582228765
11 changed files with 973 additions and 160 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
279
src/format.zig
279
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
400
src/tui.zig
400
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 <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);
|
||||
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 <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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue