zfin/src/analytics/benchmark.zig

659 lines
26 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Benchmark comparison and portfolio weighted return calculations.
///
/// Produces:
/// - Weighted benchmark returns (SPY + AGG at the portfolio's stock/bond split)
/// - Per-period portfolio weighted trailing returns (straight average)
/// - Conservative weighted return: SUM(weight_i * MIN(3Y_i, 5Y_i, 10Y_i))
///
/// The conservative estimate uses the minimum of longer-term trailing returns
/// per position, excluding 1-year (too noisy). This matches the spreadsheet
/// methodology used for forward-looking projections.
const std = @import("std");
const performance = @import("performance.zig");
const TrailingReturns = performance.TrailingReturns;
const PerformanceResult = performance.PerformanceResult;
const Allocation = @import("valuation.zig").Allocation;
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
// ── Conservative return estimation defaults ────────────────────
// These will eventually move to projections.srf config.
/// Maximum return any single position can contribute (null = no cap).
pub const default_return_cap: ?f64 = null;
/// Whether to exclude 1-year returns from the conservative MIN calculation.
pub const default_exclude_1y_from_min: bool = true;
// ── Result types ───────────────────────────────────────────────
/// Trailing returns for each standard period, as simple f64 values (annualized).
pub const ReturnsByPeriod = struct {
one_year: ?f64 = null,
three_year: ?f64 = null,
five_year: ?f64 = null,
ten_year: ?f64 = null,
/// Weekly return (non-annualized, just the 1-week price change).
week: ?f64 = null,
};
/// Full benchmark comparison result.
pub const BenchmarkComparison = struct {
/// Stock benchmark (e.g. SPY) returns at each period.
stock_returns: ReturnsByPeriod,
/// Bond benchmark (e.g. AGG) returns at each period.
bond_returns: ReturnsByPeriod,
/// Weighted benchmark: stock_pct * stock + bond_pct * bond.
benchmark_returns: ReturnsByPeriod,
/// Portfolio weighted average returns (straight weight * return per period).
portfolio_returns: ReturnsByPeriod,
/// Conservative estimate: SUM(weight * MIN(3Y, 5Y, 10Y)) per position.
conservative_return: f64,
/// Actual stock allocation (fraction).
stock_pct: f64,
/// Actual bond allocation (fraction, includes cash and CDs).
bond_pct: f64,
};
/// Per-position data needed for weighted return calculations.
pub const PositionReturn = struct {
symbol: []const u8,
weight: f64,
returns: TrailingReturns,
};
// ── Allocation split ───────────────────────────────────────────
/// Result of deriving the stock/bond/unclassified allocation split.
pub const AllocationSplit = struct {
/// Fraction of portfolio in equities (0.01.0).
stock_pct: f64,
/// Fraction of portfolio in bonds + cash + CDs (0.01.0).
bond_pct: f64,
/// Total market value classified as bonds.
bond_value: f64,
/// Total cash + CD face value.
cash_cd_value: f64,
/// Total market value that could not be classified (no metadata entry).
unclassified_value: f64,
};
/// Derive the stock/bond allocation split from portfolio allocations and
/// classification metadata.
///
/// Positions are classified using `classifications`:
/// - asset_class == "Bonds" → bond
/// - Everything else with a classification entry → stock
/// - No classification entry → unclassified
///
/// Cash and CDs are always counted as bonds (fixed-income side).
/// Unclassified positions are reported separately so the caller can
/// decide how to handle them (e.g. treat as stock, warn, etc.).
pub fn deriveAllocationSplit(
allocations: []const Allocation,
classifications: []const ClassificationEntry,
total_value: f64,
cash_value: f64,
cd_value: f64,
) AllocationSplit {
var bond_value: f64 = 0;
var classified_value: f64 = 0;
for (allocations) |a| {
var found = false;
for (classifications) |entry| {
if (std.mem.eql(u8, entry.symbol, a.symbol)) {
found = true;
if (entry.asset_class) |ac| {
if (std.mem.eql(u8, ac, "Bonds")) {
bond_value += a.market_value;
}
}
break;
}
}
if (found) {
classified_value += a.market_value;
}
}
const cash_cd_value = cash_value + cd_value;
const bond_plus_cash = bond_value + cash_cd_value;
// Unclassified = allocations not found in classifications (options, new positions, etc.)
// Note: cash/CDs are not in allocations, so total_value includes them separately.
const unclassified_value = total_value - classified_value - cash_cd_value;
const stock_pct = if (total_value > 0) (total_value - bond_plus_cash - @max(unclassified_value, 0)) / total_value else 0.75;
const bond_pct = if (total_value > 0) bond_plus_cash / total_value else 0.25;
return .{
.stock_pct = stock_pct,
.bond_pct = bond_pct,
.bond_value = bond_value,
.cash_cd_value = cash_cd_value,
.unclassified_value = @max(unclassified_value, 0),
};
}
// ── Computation functions ──────────────────────────────────────
/// Extract the annualized return from a PerformanceResult, if available.
fn annualized(pr: ?PerformanceResult) ?f64 {
const r = pr orelse return null;
return r.annualized_return orelse r.total_return;
}
/// Convert TrailingReturns to simple f64 ReturnsByPeriod.
pub fn toReturnsByPeriod(tr: TrailingReturns) ReturnsByPeriod {
return .{
.one_year = annualized(tr.one_year),
.three_year = annualized(tr.three_year),
.five_year = annualized(tr.five_year),
.ten_year = annualized(tr.ten_year),
};
}
/// Compute the weighted average of two ReturnsByPeriod values.
fn blendReturns(a: ReturnsByPeriod, a_weight: f64, b: ReturnsByPeriod, b_weight: f64) ReturnsByPeriod {
return .{
.one_year = blendOptional(a.one_year, a_weight, b.one_year, b_weight),
.three_year = blendOptional(a.three_year, a_weight, b.three_year, b_weight),
.five_year = blendOptional(a.five_year, a_weight, b.five_year, b_weight),
.ten_year = blendOptional(a.ten_year, a_weight, b.ten_year, b_weight),
.week = blendOptional(a.week, a_weight, b.week, b_weight),
};
}
fn blendOptional(a: ?f64, a_w: f64, b: ?f64, b_w: f64) ?f64 {
if (a != null and b != null)
return a.? * a_w + b.? * b_w;
if (a != null) return a.? * a_w;
if (b != null) return b.? * b_w;
return null;
}
/// Compute portfolio weighted average returns across all positions.
/// Each position's return is weighted by its portfolio weight.
pub fn portfolioWeightedReturns(positions: []const PositionReturn) ReturnsByPeriod {
var result = ReturnsByPeriod{};
var sum_1y: f64 = 0;
var w_1y: f64 = 0;
var sum_3y: f64 = 0;
var w_3y: f64 = 0;
var sum_5y: f64 = 0;
var w_5y: f64 = 0;
var sum_10y: f64 = 0;
var w_10y: f64 = 0;
for (positions) |pos| {
if (annualized(pos.returns.one_year)) |r| {
sum_1y += pos.weight * r;
w_1y += pos.weight;
}
if (annualized(pos.returns.three_year)) |r| {
sum_3y += pos.weight * r;
w_3y += pos.weight;
}
if (annualized(pos.returns.five_year)) |r| {
sum_5y += pos.weight * r;
w_5y += pos.weight;
}
if (annualized(pos.returns.ten_year)) |r| {
sum_10y += pos.weight * r;
w_10y += pos.weight;
}
}
// Normalize by total weight for each period (handles positions with missing data)
if (w_1y > 0) result.one_year = sum_1y / w_1y;
if (w_3y > 0) result.three_year = sum_3y / w_3y;
if (w_5y > 0) result.five_year = sum_5y / w_5y;
if (w_10y > 0) result.ten_year = sum_10y / w_10y;
return result;
}
/// Compute the conservative weighted return estimate.
///
/// For each position: take MIN(3Y, 5Y, 10Y) annualized return (skipping 1Y).
/// Apply optional cap. Multiply by weight. Sum across all positions.
/// Normalize by total weight of positions that had at least one valid period.
pub fn conservativeWeightedReturn(
positions: []const PositionReturn,
return_cap: ?f64,
exclude_1y: bool,
) f64 {
var total: f64 = 0;
var total_weight: f64 = 0;
for (positions) |pos| {
var min_return: ?f64 = null;
// Include 1Y only if not excluded
if (!exclude_1y) {
if (annualized(pos.returns.one_year)) |r| {
min_return = r;
}
}
if (annualized(pos.returns.three_year)) |r| {
min_return = if (min_return) |m| @min(m, r) else r;
}
if (annualized(pos.returns.five_year)) |r| {
min_return = if (min_return) |m| @min(m, r) else r;
}
if (annualized(pos.returns.ten_year)) |r| {
min_return = if (min_return) |m| @min(m, r) else r;
}
if (min_return) |mr| {
// Apply cap if configured
const capped = if (return_cap) |cap| @min(mr, cap) else mr;
total += pos.weight * capped;
total_weight += pos.weight;
}
}
if (total_weight > 0) return total / total_weight;
return 0;
}
/// Build a full benchmark comparison from component data.
pub fn buildComparison(
stock_trailing: TrailingReturns,
bond_trailing: TrailingReturns,
stock_pct: f64,
bond_pct: f64,
positions: []const PositionReturn,
stock_week: ?f64,
bond_week: ?f64,
) BenchmarkComparison {
var stock_r = toReturnsByPeriod(stock_trailing);
stock_r.week = stock_week;
var bond_r = toReturnsByPeriod(bond_trailing);
bond_r.week = bond_week;
const benchmark = blendReturns(stock_r, stock_pct, bond_r, bond_pct);
const portfolio = portfolioWeightedReturns(positions);
const conservative = conservativeWeightedReturn(positions, default_return_cap, default_exclude_1y_from_min);
return .{
.stock_returns = stock_r,
.bond_returns = bond_r,
.benchmark_returns = benchmark,
.portfolio_returns = portfolio,
.conservative_return = conservative,
.stock_pct = stock_pct,
.bond_pct = bond_pct,
};
}
// ── Tests ──────────────────────────────────────────────────────
const Date = @import("../Date.zig");
fn makePR(total: f64, ann: ?f64) PerformanceResult {
return .{
.total_return = total,
.annualized_return = ann,
.from = Date.fromYmd(2020, 1, 1),
.to = Date.fromYmd(2024, 1, 1),
};
}
test "toReturnsByPeriod extracts annualized returns" {
const tr = TrailingReturns{
.one_year = makePR(0.10, 0.10),
.three_year = makePR(0.30, 0.09),
.five_year = makePR(0.50, 0.085),
.ten_year = null,
};
const r = toReturnsByPeriod(tr);
try std.testing.expectApproxEqAbs(@as(f64, 0.10), r.one_year.?, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.09), r.three_year.?, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.085), r.five_year.?, 0.001);
try std.testing.expect(r.ten_year == null);
}
test "blendReturns weighted average" {
const a = ReturnsByPeriod{ .one_year = 0.20, .three_year = 0.15 };
const b = ReturnsByPeriod{ .one_year = 0.05, .three_year = 0.04 };
const result = blendReturns(a, 0.75, b, 0.25);
// 0.75 * 0.20 + 0.25 * 0.05 = 0.1625
try std.testing.expectApproxEqAbs(@as(f64, 0.1625), result.one_year.?, 0.0001);
// 0.75 * 0.15 + 0.25 * 0.04 = 0.1225
try std.testing.expectApproxEqAbs(@as(f64, 0.1225), result.three_year.?, 0.0001);
}
test "blendReturns handles null" {
const a = ReturnsByPeriod{ .one_year = 0.20, .three_year = null };
const b = ReturnsByPeriod{ .one_year = null, .three_year = 0.04 };
const result = blendReturns(a, 0.75, b, 0.25);
// Only a has 1Y: 0.20 * 0.75 = 0.15
try std.testing.expectApproxEqAbs(@as(f64, 0.15), result.one_year.?, 0.0001);
// Only b has 3Y: 0.04 * 0.25 = 0.01
try std.testing.expectApproxEqAbs(@as(f64, 0.01), result.three_year.?, 0.0001);
}
test "portfolioWeightedReturns basic" {
const positions = [_]PositionReturn{
.{ .symbol = "SPY", .weight = 0.60, .returns = .{
.one_year = makePR(0.20, 0.20),
.three_year = makePR(0.50, 0.15),
} },
.{ .symbol = "AGG", .weight = 0.40, .returns = .{
.one_year = makePR(0.05, 0.05),
.three_year = makePR(0.12, 0.04),
} },
};
const r = portfolioWeightedReturns(&positions);
// 1Y: (0.60 * 0.20 + 0.40 * 0.05) / (0.60 + 0.40) = 0.14
try std.testing.expectApproxEqAbs(@as(f64, 0.14), r.one_year.?, 0.001);
// 3Y: (0.60 * 0.15 + 0.40 * 0.04) / (0.60 + 0.40) = 0.106
try std.testing.expectApproxEqAbs(@as(f64, 0.106), r.three_year.?, 0.001);
}
test "portfolioWeightedReturns normalizes by available weight" {
// Position B has no 3Y data — should normalize by weight of those that do
const positions = [_]PositionReturn{
.{ .symbol = "SPY", .weight = 0.60, .returns = .{
.one_year = makePR(0.20, 0.20),
.three_year = makePR(0.50, 0.15),
} },
.{ .symbol = "NEW", .weight = 0.40, .returns = .{
.one_year = makePR(0.30, 0.30),
.three_year = null,
} },
};
const r = portfolioWeightedReturns(&positions);
// 1Y: (0.60 * 0.20 + 0.40 * 0.30) / 1.0 = 0.24
try std.testing.expectApproxEqAbs(@as(f64, 0.24), r.one_year.?, 0.001);
// 3Y: (0.60 * 0.15) / 0.60 = 0.15 (only SPY contributes)
try std.testing.expectApproxEqAbs(@as(f64, 0.15), r.three_year.?, 0.001);
}
test "conservativeWeightedReturn uses MIN of 3Y/5Y/10Y" {
const positions = [_]PositionReturn{
.{
.symbol = "NVDA",
.weight = 0.05,
.returns = .{
.one_year = makePR(0.32, 0.32), // excluded from MIN
.three_year = makePR(4.0, 1.28), // 128% (highest)
.five_year = makePR(10.0, 0.69), // 69% (lowest)
.ten_year = makePR(50.0, 0.74), // 74%
},
},
.{ .symbol = "SPY", .weight = 0.95, .returns = .{
.three_year = makePR(0.50, 0.15),
.five_year = makePR(0.80, 0.12),
.ten_year = makePR(2.0, 0.14),
} },
};
const result = conservativeWeightedReturn(&positions, null, true);
// NVDA: MIN(1.28, 0.69, 0.74) = 0.69, weight 0.05
// SPY: MIN(0.15, 0.12, 0.14) = 0.12, weight 0.95
// Total: (0.05 * 0.69 + 0.95 * 0.12) / (0.05 + 0.95) = 0.0345 + 0.114 = 0.1485
try std.testing.expectApproxEqAbs(@as(f64, 0.1485), result, 0.001);
}
test "conservativeWeightedReturn with cap" {
const positions = [_]PositionReturn{
.{ .symbol = "NVDA", .weight = 1.0, .returns = .{
.three_year = makePR(4.0, 1.28),
.five_year = makePR(10.0, 0.69),
.ten_year = makePR(50.0, 0.74),
} },
};
// Without cap: MIN(1.28, 0.69, 0.74) = 0.69
const uncapped = conservativeWeightedReturn(&positions, null, true);
try std.testing.expectApproxEqAbs(@as(f64, 0.69), uncapped, 0.001);
// With 30% cap: MIN(1.28, 0.69, 0.74) = 0.69, then capped to 0.30
const capped = conservativeWeightedReturn(&positions, 0.30, true);
try std.testing.expectApproxEqAbs(@as(f64, 0.30), capped, 0.001);
}
test "conservativeWeightedReturn includes 1Y when not excluded" {
const positions = [_]PositionReturn{
.{
.symbol = "SPY",
.weight = 1.0,
.returns = .{
.one_year = makePR(0.05, 0.05), // lowest
.three_year = makePR(0.50, 0.15),
.five_year = makePR(0.80, 0.12),
},
},
};
// With exclude_1y=true: MIN(0.15, 0.12) = 0.12
const excluded = conservativeWeightedReturn(&positions, null, true);
try std.testing.expectApproxEqAbs(@as(f64, 0.12), excluded, 0.001);
// With exclude_1y=false: MIN(0.05, 0.15, 0.12) = 0.05
const included = conservativeWeightedReturn(&positions, null, false);
try std.testing.expectApproxEqAbs(@as(f64, 0.05), included, 0.001);
}
test "buildComparison produces consistent results" {
const stock_tr = TrailingReturns{
.one_year = makePR(0.23, 0.23),
.three_year = makePR(0.60, 0.17),
};
const bond_tr = TrailingReturns{
.one_year = makePR(0.04, 0.04),
.three_year = makePR(0.10, 0.03),
};
const positions = [_]PositionReturn{
.{ .symbol = "VTI", .weight = 0.80, .returns = .{
.one_year = makePR(0.22, 0.22),
.three_year = makePR(0.55, 0.16),
.five_year = makePR(0.80, 0.13),
} },
.{ .symbol = "BND", .weight = 0.20, .returns = .{
.one_year = makePR(0.03, 0.03),
.three_year = makePR(0.09, 0.03),
.five_year = makePR(0.10, 0.02),
} },
};
const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions, null, null);
// Benchmark 1Y: 0.77 * 0.23 + 0.23 * 0.04 = 0.1771 + 0.0092 = 0.1863
try std.testing.expectApproxEqAbs(@as(f64, 0.1863), result.benchmark_returns.one_year.?, 0.001);
// Portfolio 1Y: (0.80 * 0.22 + 0.20 * 0.03) / 1.0 = 0.182
try std.testing.expectApproxEqAbs(@as(f64, 0.182), result.portfolio_returns.one_year.?, 0.001);
// Conservative: VTI MIN(0.16, 0.13) = 0.13 * 0.80 + BND MIN(0.03, 0.02) = 0.02 * 0.20
// = 0.104 + 0.004 = 0.108
try std.testing.expectApproxEqAbs(@as(f64, 0.108), result.conservative_return, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.77), result.stock_pct, 0.001);
}
test "conservativeWeightedReturn with no valid periods" {
const positions = [_]PositionReturn{
.{ .symbol = "NEW", .weight = 1.0, .returns = .{
.one_year = makePR(0.10, 0.10),
} },
};
// exclude_1y=true, and only 1Y data available → no valid periods
const result = conservativeWeightedReturn(&positions, null, true);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result, 0.001);
}
test "portfolioWeightedReturns empty positions" {
const positions = [_]PositionReturn{};
const r = portfolioWeightedReturns(&positions);
try std.testing.expect(r.one_year == null);
try std.testing.expect(r.three_year == null);
}
test "annualized falls back to total_return" {
// When annualized_return is null, should use total_return
const pr = PerformanceResult{
.total_return = 0.15,
.annualized_return = null,
.from = Date.fromYmd(2024, 1, 1),
.to = Date.fromYmd(2024, 6, 1),
};
try std.testing.expectApproxEqAbs(@as(f64, 0.15), annualized(pr).?, 0.001);
try std.testing.expect(annualized(null) == null);
}
test "blendReturns both null" {
const a = ReturnsByPeriod{};
const b = ReturnsByPeriod{};
const result = blendReturns(a, 0.5, b, 0.5);
try std.testing.expect(result.one_year == null);
try std.testing.expect(result.five_year == null);
}
test "conservativeWeightedReturn single position single period" {
const positions = [_]PositionReturn{
.{ .symbol = "SPY", .weight = 1.0, .returns = .{
.five_year = makePR(0.80, 0.125),
} },
};
// Only 5Y available → MIN is just 0.125
const result = conservativeWeightedReturn(&positions, null, true);
try std.testing.expectApproxEqAbs(@as(f64, 0.125), result, 0.001);
}
test "buildComparison with week returns" {
const stock_tr = TrailingReturns{
.one_year = makePR(0.20, 0.20),
};
const bond_tr = TrailingReturns{
.one_year = makePR(0.03, 0.03),
};
const positions = [_]PositionReturn{};
const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, -0.01, 0.005);
// Week returns should be set
try std.testing.expectApproxEqAbs(@as(f64, -0.01), result.stock_returns.week.?, 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, 0.005), result.bond_returns.week.?, 0.0001);
// Benchmark week: 0.80 * -0.01 + 0.20 * 0.005 = -0.007
try std.testing.expectApproxEqAbs(@as(f64, -0.007), result.benchmark_returns.week.?, 0.0001);
}
test "portfolioWeightedReturns all periods populated" {
const positions = [_]PositionReturn{
.{ .symbol = "VTI", .weight = 0.50, .returns = .{
.one_year = makePR(0.20, 0.20),
.three_year = makePR(0.50, 0.15),
.five_year = makePR(0.80, 0.13),
.ten_year = makePR(2.0, 0.12),
} },
.{ .symbol = "BND", .weight = 0.50, .returns = .{
.one_year = makePR(0.04, 0.04),
.three_year = makePR(0.10, 0.03),
.five_year = makePR(0.15, 0.03),
.ten_year = makePR(0.30, 0.03),
} },
};
const r = portfolioWeightedReturns(&positions);
// 10Y: (0.50 * 0.12 + 0.50 * 0.03) / 1.0 = 0.075
try std.testing.expectApproxEqAbs(@as(f64, 0.075), r.ten_year.?, 0.001);
// 5Y: (0.50 * 0.13 + 0.50 * 0.03) / 1.0 = 0.08
try std.testing.expectApproxEqAbs(@as(f64, 0.08), r.five_year.?, 0.001);
}
fn makeAlloc(symbol: []const u8, mv: f64, weight: f64) Allocation {
return .{
.symbol = symbol,
.display_symbol = symbol,
.shares = 10,
.avg_cost = 100,
.current_price = mv / 10,
.market_value = mv,
.cost_basis = 1000,
.weight = weight,
.unrealized_gain_loss = mv - 1000,
.unrealized_return = 0.0,
};
}
test "deriveAllocationSplit basic stock/bond split" {
const allocs = [_]Allocation{
makeAlloc("SPY", 700_000, 0.70),
makeAlloc("AAPL", 100_000, 0.10),
makeAlloc("BND", 150_000, 0.15),
};
const classes = [_]ClassificationEntry{
.{ .symbol = "SPY", .asset_class = "US Large Cap" },
.{ .symbol = "AAPL", .asset_class = "US Large Cap" },
.{ .symbol = "BND", .asset_class = "Bonds" },
};
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 40_000, 10_000);
// Bonds: BND $150K + cash $40K + CD $10K = $200K → 20%
try std.testing.expectApproxEqAbs(@as(f64, 150_000), result.bond_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.cash_cd_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.bond_pct, 0.01);
// Stock: $800K → 80% (no unclassified since all are in metadata)
try std.testing.expectApproxEqAbs(@as(f64, 0.80), result.stock_pct, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.unclassified_value, 1.0);
}
test "deriveAllocationSplit with unclassified positions" {
const allocs = [_]Allocation{
makeAlloc("SPY", 600_000, 0.60),
makeAlloc("MYSTERY", 100_000, 0.10),
};
const classes = [_]ClassificationEntry{
.{ .symbol = "SPY", .asset_class = "US Large Cap" },
// MYSTERY has no classification entry
};
const result = deriveAllocationSplit(&allocs, &classes, 800_000, 50_000, 50_000);
// Bonds: $0 + cash $50K + CD $50K = $100K
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.cash_cd_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_value, 1.0);
// Unclassified: MYSTERY $100K
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.unclassified_value, 1.0);
// Stock: $800K - $100K bonds - $100K unclassified = $600K → 75%
try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01);
// Bond pct: $100K / $800K = 12.5%
try std.testing.expectApproxEqAbs(@as(f64, 0.125), result.bond_pct, 0.01);
}
test "deriveAllocationSplit empty portfolio" {
const allocs = [_]Allocation{};
const classes = [_]ClassificationEntry{};
const result = deriveAllocationSplit(&allocs, &classes, 0, 0, 0);
// Default fallback
try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.25), result.bond_pct, 0.01);
}
test "deriveAllocationSplit no metadata" {
const allocs = [_]Allocation{
makeAlloc("SPY", 500_000, 0.50),
makeAlloc("BND", 300_000, 0.30),
};
const classes = [_]ClassificationEntry{}; // no metadata at all
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 100_000, 100_000);
// Everything is unclassified except cash/CDs
try std.testing.expectApproxEqAbs(@as(f64, 200_000), result.cash_cd_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 800_000), result.unclassified_value, 1.0);
// Stock = total - bonds - unclassified = $1M - $200K - $800K = $0 → 0%
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.stock_pct, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.bond_pct, 0.01);
}
test "deriveAllocationSplit stock and bond pct sum with unclassified" {
const allocs = [_]Allocation{
makeAlloc("SPY", 500_000, 0.50),
makeAlloc("BND", 200_000, 0.20),
makeAlloc("NEW", 50_000, 0.05),
};
const classes = [_]ClassificationEntry{
.{ .symbol = "SPY", .asset_class = "US Large Cap" },
.{ .symbol = "BND", .asset_class = "Bonds" },
};
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 200_000, 50_000);
// stock + bond + unclassified/total should account for everything
const unclass_pct = result.unclassified_value / 1_000_000;
try std.testing.expectApproxEqAbs(@as(f64, 1.0), result.stock_pct + result.bond_pct + unclass_pct, 0.01);
}