659 lines
26 KiB
Zig
659 lines
26 KiB
Zig
/// 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.0–1.0).
|
||
stock_pct: f64,
|
||
/// Fraction of portfolio in bonds + cash + CDs (0.0–1.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);
|
||
}
|