zfin/src/analytics/benchmark.zig

948 lines
39 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 ────────────────────
// The per-position return cap is configured in projections.srf
// (`UserConfig.return_cap`) and threaded into `buildComparison` by the
// view layer. Excluding 1-year returns from the MIN is not yet
// user-configurable, so it stays a module-level default here.
/// 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.
/// `returns.week` carries the 1-week return, populated automatically
/// by `performance.trailingReturns`.
pub const PositionReturn = struct {
symbol: []const u8,
weight: f64,
returns: TrailingReturns,
};
// ── Allocation split ───────────────────────────────────────────
/// Result of deriving the equity / fixed-income / cash / other allocation split.
pub const AllocationSplit = struct {
/// Fraction of portfolio in equities (0.0-1.0). Sum of every
/// classification entry whose `bucketSector(sector)` is "Equity",
/// weighted by `entry.pct`.
stock_pct: f64,
/// Fraction of portfolio in fixed income (0.0-1.0). Excludes
/// cash. The header line displays cash separately as `cash_pct`.
bond_pct: f64,
/// Fraction of portfolio in cash + CDs + fund-internal cash
/// equivalents (0.0-1.0).
cash_pct: f64,
/// Fraction of portfolio in derivatives, real property,
/// sentinels, and unrecognized sectors (0.0-1.0).
other_pct: f64,
/// Total dollar value classified as fixed income (excludes cash).
bond_value: f64,
/// Total cash + CD face value + fund-internal cash sleeves.
cash_cd_value: f64,
/// Total market value that could not be classified (no metadata entry).
unclassified_value: f64,
};
/// Derive the equity / fixed-income / cash / other allocation
/// split from portfolio allocations and classification metadata.
///
/// For each allocation, every matching classification entry
/// contributes `market_value × (pct / 100)` into the bucket
/// returned by `analysis.bucketSector(entry.sector)`. This means:
///
/// - Multi-sector funds (e.g. FAGIX with 48% Debt / Corporate
/// and 22% Equity / Corporate) split correctly across buckets
/// proportional to their NPORT-P sector decomposition.
/// - Pure-debt funds (VBTLX) land in `bond_pct` even when their
/// `asset_class` is `Fund` rather than `Bonds`.
/// - GICS-sectored stocks (NVDA -> Technology) land in `stock_pct`.
/// - Derivatives, real property, and sentinel sectors land in
/// `other_pct` and are silently excluded from the binary
/// stock/bond header.
///
/// Negative weights from leveraged funds (PTY's
/// `Repurchase Agreement / Other` repo liability) flow through
/// honestly into bucket math. Diluted across a diversified
/// portfolio, the visual effect is negligible.
///
/// Literal cash + CDs are added directly to `cash_pct` (and
/// `cash_cd_value`). Allocations not found in `classifications`
/// are reported via `unclassified_value`.
pub fn deriveAllocationSplit(
allocations: []const Allocation,
classifications: []const ClassificationEntry,
total_value: f64,
cash_value: f64,
cd_value: f64,
) AllocationSplit {
const analysis = @import("analysis.zig");
var stock_value: f64 = 0;
var bond_value: f64 = 0;
var cash_classified_value: f64 = 0;
var other_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)) continue;
found = true;
const portion = a.market_value * (entry.pct / 100.0);
// Bucket via `sector` if present (richer signal).
// Fall back to `asset_class` for legacy hand-written
// entries with no sector. Last resort: Other.
const bucket = if (entry.sector) |s|
analysis.bucketSector(s)
else if (entry.asset_class) |ac|
analysis.bucketAssetClass(ac)
else
analysis.bucket_other;
if (std.mem.eql(u8, bucket, analysis.bucket_equity)) {
stock_value += portion;
} else if (std.mem.eql(u8, bucket, analysis.bucket_fixed_income)) {
bond_value += portion;
} else if (std.mem.eql(u8, bucket, analysis.bucket_cash)) {
cash_classified_value += portion;
} else {
other_value += portion;
}
}
if (found) {
classified_value += a.market_value;
}
}
const literal_cash = cash_value + cd_value;
const total_cash = cash_classified_value + literal_cash;
// 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 - literal_cash;
if (total_value <= 0) {
// Empty portfolio: fall back to a sensible default for
// benchmark blending math (75/25 stock/bond).
return .{
.stock_pct = 0.75,
.bond_pct = 0.25,
.cash_pct = 0,
.other_pct = 0,
.bond_value = 0,
.cash_cd_value = 0,
.unclassified_value = 0,
};
}
return .{
.stock_pct = stock_value / total_value,
.bond_pct = bond_value / total_value,
.cash_pct = total_cash / total_value,
.other_pct = other_value / total_value,
.bond_value = bond_value,
.cash_cd_value = total_cash,
.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),
.week = tr.week,
};
}
/// 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;
var sum_wk: f64 = 0;
var w_wk: 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;
}
if (pos.returns.week) |r| {
sum_wk += pos.weight * r;
w_wk += 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;
if (w_wk > 0) result.week = sum_wk / w_wk;
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.
///
/// `return_cap` is the per-position ceiling (a fraction, e.g. 0.30 for
/// 30%) applied to each position's conservative MIN return before
/// weighting; `null` means no cap. It originates from
/// `projections.srf` (`UserConfig.return_cap`, stored as a percent and
/// converted to a fraction at the view boundary).
pub fn buildComparison(
stock_trailing: TrailingReturns,
bond_trailing: TrailingReturns,
stock_pct: f64,
bond_pct: f64,
positions: []const PositionReturn,
return_cap: ?f64,
) BenchmarkComparison {
// `stock_trailing.week` and `bond_trailing.week` propagate
// through `toReturnsByPeriod` automatically - see
// `performance.trailingReturns`, which populates the field
// alongside the longer trailing periods.
const stock_r = toReturnsByPeriod(stock_trailing);
const bond_r = toReturnsByPeriod(bond_trailing);
const benchmark = blendReturns(stock_r, stock_pct, bond_r, bond_pct);
const portfolio = portfolioWeightedReturns(positions);
const conservative = conservativeWeightedReturn(positions, 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);
// 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);
try std.testing.expect(r.week == null);
}
test "portfolioWeightedReturns aggregates week alongside annualized periods" {
const positions = [_]PositionReturn{
.{ .symbol = "VTI", .weight = 0.60, .returns = .{
.one_year = makePR(0.20, 0.20),
.week = 0.02,
} },
.{ .symbol = "BND", .weight = 0.40, .returns = .{
.one_year = makePR(0.03, 0.03),
.week = -0.01,
} },
};
const r = portfolioWeightedReturns(&positions);
// Weighted week: 0.60 * 0.02 + 0.40 * -0.01 = 0.008
try std.testing.expectApproxEqAbs(@as(f64, 0.008), r.week.?, 0.0001);
// 1Y still works alongside.
try std.testing.expectApproxEqAbs(@as(f64, 0.132), r.one_year.?, 0.0001);
}
test "portfolioWeightedReturns week handles null on some positions" {
// Position B is missing week data - it shouldn't poison A's
// contribution. Aggregate normalizes by weight of positions
// that DID supply week.
const positions = [_]PositionReturn{
.{ .symbol = "A", .weight = 0.50, .returns = .{ .week = 0.04 } },
.{ .symbol = "B", .weight = 0.50, .returns = .{} },
};
const r = portfolioWeightedReturns(&positions);
// Only A had a week reading: 0.04 * 0.50 / 0.50 = 0.04
try std.testing.expectApproxEqAbs(@as(f64, 0.04), r.week.?, 0.0001);
}
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" {
// Week returns now flow through `TrailingReturns.week` rather
// than separate parameters - `performance.trailingReturns`
// populates the field automatically.
const stock_tr = TrailingReturns{
.one_year = makePR(0.20, 0.20),
.week = -0.01,
};
const bond_tr = TrailingReturns{
.one_year = makePR(0.03, 0.03),
.week = 0.005,
};
const positions = [_]PositionReturn{};
const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, null);
// 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 "buildComparison applies the return cap to conservative_return" {
// A single high-flyer position (NVDA-shaped) whose MIN(3Y,5Y,10Y)
// is 0.69. With no cap the conservative return is 0.69; a 0.30 cap
// threaded through buildComparison clamps it to 0.30.
const stock_tr = TrailingReturns{ .three_year = makePR(0.50, 0.15) };
const bond_tr = TrailingReturns{ .three_year = makePR(0.10, 0.03) };
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),
} },
};
const uncapped = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, null);
try std.testing.expectApproxEqAbs(@as(f64, 0.69), uncapped.conservative_return, 0.001);
const capped = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, 0.30);
try std.testing.expectApproxEqAbs(@as(f64, 0.30), capped.conservative_return, 0.001);
}
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 via sector" {
// BND has Debt sector -> Fixed Income bucket. SPY/AAPL have
// GICS sectors -> Equity bucket. Cash/CDs add to cash_pct.
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", .sector = "Financial Services" },
.{ .symbol = "AAPL", .sector = "Technology" },
.{ .symbol = "BND", .sector = "Debt / Corporate" },
};
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 40_000, 10_000);
// Bonds: BND $150K -> 15%
try std.testing.expectApproxEqAbs(@as(f64, 150_000), result.bond_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.15), result.bond_pct, 0.01);
// Cash: $40K + $10K = $50K -> 5%
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.cash_cd_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.05), result.cash_pct, 0.01);
// Stock: SPY $700K + AAPL $100K = $800K -> 80%
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", .sector = "Financial Services" },
// MYSTERY has no classification entry
};
const result = deriveAllocationSplit(&allocs, &classes, 800_000, 50_000, 50_000);
// Cash: $50K + $50K = $100K -> 12.5%
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.cash_cd_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.125), result.cash_pct, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.01);
// Unclassified: MYSTERY $100K
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.unclassified_value, 1.0);
// Stock: SPY $600K -> 75%
try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01);
}
test "deriveAllocationSplit empty portfolio" {
const allocs = [_]Allocation{};
const classes = [_]ClassificationEntry{};
const result = deriveAllocationSplit(&allocs, &classes, 0, 0, 0);
// Default fallback for blending math
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);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.cash_pct, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.other_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);
// Cash: $200K -> 20%
try std.testing.expectApproxEqAbs(@as(f64, 200_000), result.cash_cd_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.cash_pct, 0.01);
// Everything except cash is unclassified
try std.testing.expectApproxEqAbs(@as(f64, 800_000), result.unclassified_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.stock_pct, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.01);
}
test "deriveAllocationSplit: pure-debt fund with asset_class Fund maps via sector" {
// VBTLX shape: asset_class is "Fund" (not "Bonds"), but every
// sector entry is a Debt / * variant. Should land entirely
// in bond_pct via bucketSector(sector), not in unclassified.
const allocs = [_]Allocation{
makeAlloc("VBTLX", 100_000, 1.0),
};
const classes = [_]ClassificationEntry{
.{ .symbol = "VBTLX", .asset_class = "Fund", .sector = "Debt / Corporate", .pct = 60.0 },
.{ .symbol = "VBTLX", .asset_class = "Fund", .sector = "Debt / US Treasury", .pct = 30.0 },
.{ .symbol = "VBTLX", .asset_class = "Fund", .sector = "Debt / Municipal", .pct = 10.0 },
};
const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), result.bond_pct, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.stock_pct, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.cash_pct, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.bond_value, 1.0);
}
test "deriveAllocationSplit: pure-equity fund maps via sector" {
// SCHD shape: 99.7% Equity / Corporate + 0.19% short-term
// investment vehicle. Stock_pct ≈ 0.997, cash_pct ≈ 0.0019.
const allocs = [_]Allocation{
makeAlloc("SCHD", 100_000, 1.0),
};
const classes = [_]ClassificationEntry{
.{ .symbol = "SCHD", .asset_class = "Fund", .sector = "Equity / Corporate", .pct = 99.70 },
.{ .symbol = "SCHD", .asset_class = "Fund", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 0.19 },
};
const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);
try std.testing.expectApproxEqAbs(@as(f64, 0.997), result.stock_pct, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.0019), result.cash_pct, 0.0005);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.001);
}
test "deriveAllocationSplit: multi-asset fund splits across buckets" {
// FAGIX-shape: ~48% Debt + ~22% Equity + others. Should split
// across stock_pct, bond_pct, cash_pct rather than landing
// entirely in one bucket.
const allocs = [_]Allocation{
makeAlloc("FAGIX", 100_000, 1.0),
};
const classes = [_]ClassificationEntry{
.{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Debt / Corporate", .pct = 47.69 },
.{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Equity / Corporate", .pct = 22.49 },
.{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 13.37 },
.{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Loan / Corporate", .pct = 9.99 },
.{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Equity Preferred / Corporate", .pct = 3.59 },
.{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Equity / Registered Fund", .pct = 2.38 },
.{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Asset-Backed CBO/CDO / Corporate", .pct = 0.32 },
};
const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);
// Equity: 22.49 + 3.59 + 2.38 = 28.46%
try std.testing.expectApproxEqAbs(@as(f64, 0.2846), result.stock_pct, 0.001);
// Fixed Income: 47.69 + 9.99 + 0.32 = 58.00%
try std.testing.expectApproxEqAbs(@as(f64, 0.5800), result.bond_pct, 0.001);
// Cash: 13.37%
try std.testing.expectApproxEqAbs(@as(f64, 0.1337), result.cash_pct, 0.001);
}
test "deriveAllocationSplit: PTY-shape leveraged fund honestly sums negative repo" {
// PTY uses ~30% repo leverage. The negative pct flows
// through honestly into the Cash bucket (Repurchase
// Agreement -> Cash); the long sleeves stay positive.
const allocs = [_]Allocation{
makeAlloc("PTY", 100_000, 1.0),
};
const classes = [_]ClassificationEntry{
.{ .symbol = "PTY", .asset_class = "Fund", .sector = "Debt / Corporate", .pct = 41.65 },
.{ .symbol = "PTY", .asset_class = "Fund", .sector = "Loan / Corporate", .pct = 40.05 },
.{ .symbol = "PTY", .asset_class = "Fund", .sector = "Equity / Corporate", .pct = 5.78 },
.{ .symbol = "PTY", .asset_class = "Fund", .sector = "Repurchase Agreement / Other", .pct = -29.72 },
};
const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);
// Bond bucket: 41.65 + 40.05 = 81.70%
try std.testing.expectApproxEqAbs(@as(f64, 0.8170), result.bond_pct, 0.001);
// Stock bucket: 5.78%
try std.testing.expectApproxEqAbs(@as(f64, 0.0578), result.stock_pct, 0.001);
// Cash bucket: -29.72% (honest, negative).
try std.testing.expectApproxEqAbs(@as(f64, -0.2972), result.cash_pct, 0.001);
}
test "deriveAllocationSplit: derivatives go into Other (excluded from header math)" {
const allocs = [_]Allocation{
makeAlloc("FOO", 100_000, 1.0),
};
const classes = [_]ClassificationEntry{
.{ .symbol = "FOO", .asset_class = "Fund", .sector = "Equity / Corporate", .pct = 90.0 },
.{ .symbol = "FOO", .asset_class = "Fund", .sector = "Derivative / Corporate", .pct = 10.0 },
};
const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);
try std.testing.expectApproxEqAbs(@as(f64, 0.90), result.stock_pct, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.10), result.other_pct, 0.001);
// Bonds and cash unaffected.
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.cash_pct, 0.001);
}
test "deriveAllocationSplit: legacy entry with asset_class only (no sector) buckets via fallback" {
// Hand-written `metadata.srf` entries pre-EDGAR-decomposition
// sometimes have asset_class but no sector. The fallback
// path through `bucketAssetClass` should bucket these
// correctly rather than dumping them in Other.
const allocs = [_]Allocation{
makeAlloc("LEGACY", 100_000, 1.0),
};
const classes = [_]ClassificationEntry{
.{ .symbol = "LEGACY", .asset_class = "US Large Cap" },
};
const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), result.stock_pct, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.other_pct, 0.001);
}
test "deriveAllocationSplit: legacy asset_class::Bonds (no sector) maps to Fixed Income" {
const allocs = [_]Allocation{
makeAlloc("LEGACY", 100_000, 1.0),
};
const classes = [_]ClassificationEntry{
.{ .symbol = "LEGACY", .asset_class = "Bonds" },
};
const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), result.bond_pct, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.other_pct, 0.001);
}
test "deriveAllocationSplit: entry with neither sector nor asset_class lands in Other" {
// Genuinely unclassifiable: classification entry exists for
// the symbol but has neither a sector nor an asset_class.
// No fallback path is possible; goes to Other.
const allocs = [_]Allocation{
makeAlloc("BARE", 100_000, 1.0),
};
const classes = [_]ClassificationEntry{
.{ .symbol = "BARE", .geo = "US" }, // no sector, no asset_class
};
const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), result.other_pct, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.stock_pct, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.001);
}
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", .sector = "Financial Services" },
.{ .symbol = "BND", .sector = "Debt / Corporate" },
};
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 200_000, 50_000);
// stock + bond + cash + other + unclassified 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 + result.cash_pct + result.other_pct + unclass_pct,
0.01,
);
}