507 lines
22 KiB
Zig
507 lines
22 KiB
Zig
const std = @import("std");
|
|
const Date = @import("../models/date.zig").Date;
|
|
const Candle = @import("../models/candle.zig").Candle;
|
|
const Dividend = @import("../models/dividend.zig").Dividend;
|
|
|
|
/// Minimum holding period (in years) before annualizing returns.
|
|
/// Set below 1.0 to handle trading-day snap (e.g. a "1-year" lookback
|
|
/// that lands on 362 days due to weekends).
|
|
const min_annualize_years = 0.95;
|
|
|
|
/// Compute CAGR from a total return over a given number of years.
|
|
/// Returns null for periods shorter than `min_annualize_years` where
|
|
/// extrapolating to a full year would be misleading.
|
|
inline fn annualizedReturn(total: f64, years: f64) ?f64 {
|
|
if (years < min_annualize_years) return null;
|
|
return std.math.pow(f64, 1.0 + total, 1.0 / years) - 1.0;
|
|
}
|
|
|
|
/// Performance calculation results, Morningstar-style.
|
|
pub const PerformanceResult = struct {
|
|
/// Total return over the period (e.g., 0.25 = 25%)
|
|
total_return: f64,
|
|
/// Annualized return (for periods > 1 year)
|
|
annualized_return: ?f64,
|
|
/// Start date used
|
|
from: Date,
|
|
/// End date used
|
|
to: Date,
|
|
};
|
|
|
|
/// Compute total return from adjusted close prices.
|
|
/// Candles must be sorted by date ascending.
|
|
/// `from` snaps forward (first trading day on/after), `to` snaps backward.
|
|
pub fn totalReturnFromAdjClose(candles: []const Candle, from: Date, to: Date) ?PerformanceResult {
|
|
return totalReturnFromAdjCloseSnap(candles, from, to, .forward);
|
|
}
|
|
|
|
/// Same as totalReturnFromAdjClose but both dates snap backward
|
|
/// (last trading day on or before). Used for month-end methodology where
|
|
/// both from and to represent month-end reference dates.
|
|
fn totalReturnFromAdjCloseBackward(candles: []const Candle, from: Date, to: Date) ?PerformanceResult {
|
|
return totalReturnFromAdjCloseSnap(candles, from, to, .backward);
|
|
}
|
|
|
|
fn totalReturnFromAdjCloseSnap(candles: []const Candle, from: Date, to: Date, start_dir: SearchDirection) ?PerformanceResult {
|
|
const start = findNearestCandle(candles, from, start_dir) orelse return null;
|
|
const end = findNearestCandle(candles, to, .backward) orelse return null;
|
|
|
|
if (start.adj_close == 0) return null;
|
|
|
|
const total = (end.adj_close / start.adj_close) - 1.0;
|
|
const years = Date.yearsBetween(start.date, end.date);
|
|
|
|
return .{
|
|
.total_return = total,
|
|
.annualized_return = annualizedReturn(total, years),
|
|
.from = start.date,
|
|
.to = end.date,
|
|
};
|
|
}
|
|
|
|
/// Compute total return with manual dividend reinvestment.
|
|
/// Uses raw close prices and dividend records independently.
|
|
/// Candles and dividends must be sorted by date ascending.
|
|
/// `from` snaps forward, `to` snaps backward.
|
|
pub fn totalReturnWithDividends(
|
|
candles: []const Candle,
|
|
dividends: []const Dividend,
|
|
from: Date,
|
|
to: Date,
|
|
) ?PerformanceResult {
|
|
return totalReturnWithDividendsSnap(candles, dividends, from, to, .forward);
|
|
}
|
|
|
|
/// Same as totalReturnWithDividends but both dates snap backward.
|
|
fn totalReturnWithDividendsBackward(
|
|
candles: []const Candle,
|
|
dividends: []const Dividend,
|
|
from: Date,
|
|
to: Date,
|
|
) ?PerformanceResult {
|
|
return totalReturnWithDividendsSnap(candles, dividends, from, to, .backward);
|
|
}
|
|
|
|
fn totalReturnWithDividendsSnap(
|
|
candles: []const Candle,
|
|
dividends: []const Dividend,
|
|
from: Date,
|
|
to: Date,
|
|
start_dir: SearchDirection,
|
|
) ?PerformanceResult {
|
|
const start = findNearestCandle(candles, from, start_dir) orelse return null;
|
|
const end = findNearestCandle(candles, to, .backward) orelse return null;
|
|
|
|
if (start.close == 0) return null;
|
|
|
|
// Simulate: start with 1 share, reinvest dividends at ex-date close
|
|
var shares: f64 = 1.0;
|
|
|
|
for (dividends) |div| {
|
|
if (div.ex_date.lessThan(start.date)) continue;
|
|
if (end.date.lessThan(div.ex_date)) break;
|
|
|
|
// Find close price on or near the ex-date
|
|
const price_candle = findNearestCandle(candles, div.ex_date, .backward) orelse continue;
|
|
if (price_candle.close > 0) {
|
|
shares += (div.amount * shares) / price_candle.close;
|
|
}
|
|
}
|
|
|
|
const final_value = shares * end.close;
|
|
const total = (final_value / start.close) - 1.0;
|
|
const years = Date.yearsBetween(start.date, end.date);
|
|
|
|
return .{
|
|
.total_return = total,
|
|
.annualized_return = annualizedReturn(total, years),
|
|
.from = start.date,
|
|
.to = end.date,
|
|
};
|
|
}
|
|
|
|
/// Convenience: compute 1yr, 3yr, 5yr, 10yr trailing returns from adjusted close.
|
|
/// Uses the last available date as the endpoint.
|
|
pub const TrailingReturns = struct {
|
|
one_year: ?PerformanceResult = null,
|
|
three_year: ?PerformanceResult = null,
|
|
five_year: ?PerformanceResult = null,
|
|
ten_year: ?PerformanceResult = null,
|
|
};
|
|
|
|
/// Trailing returns from exact calendar date N years ago to latest candle date.
|
|
/// Start dates snap forward to the next trading day (e.g., weekend → Monday).
|
|
pub fn trailingReturns(candles: []const Candle) TrailingReturns {
|
|
if (candles.len == 0) return .{};
|
|
|
|
const end_date = candles[candles.len - 1].date;
|
|
|
|
return .{
|
|
.one_year = totalReturnFromAdjClose(candles, end_date.subtractYears(1), end_date),
|
|
.three_year = totalReturnFromAdjClose(candles, end_date.subtractYears(3), end_date),
|
|
.five_year = totalReturnFromAdjClose(candles, end_date.subtractYears(5), end_date),
|
|
.ten_year = totalReturnFromAdjClose(candles, end_date.subtractYears(10), end_date),
|
|
};
|
|
}
|
|
|
|
/// Same as trailingReturns but with dividend reinvestment.
|
|
pub fn trailingReturnsWithDividends(
|
|
candles: []const Candle,
|
|
dividends: []const Dividend,
|
|
) TrailingReturns {
|
|
if (candles.len == 0) return .{};
|
|
|
|
const end_date = candles[candles.len - 1].date;
|
|
|
|
return .{
|
|
.one_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(1), end_date),
|
|
.three_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(3), end_date),
|
|
.five_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(5), end_date),
|
|
.ten_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(10), end_date),
|
|
};
|
|
}
|
|
|
|
/// Morningstar-style trailing returns using month-end reference dates.
|
|
/// End date = last calendar day of prior month. Start date = that month-end minus N years.
|
|
/// Both dates snap backward to the last trading day on or before, matching
|
|
/// Morningstar's "last business day of the month" convention.
|
|
pub fn trailingReturnsMonthEnd(candles: []const Candle, today: Date) TrailingReturns {
|
|
if (candles.len == 0) return .{};
|
|
|
|
// End reference = last day of the prior month (snaps backward to last trading day)
|
|
const month_end = today.lastDayOfPriorMonth();
|
|
|
|
return .{
|
|
.one_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(1), month_end),
|
|
.three_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(3), month_end),
|
|
.five_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(5), month_end),
|
|
.ten_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(10), month_end),
|
|
};
|
|
}
|
|
|
|
/// Same as trailingReturnsMonthEnd but with dividend reinvestment.
|
|
pub fn trailingReturnsMonthEndWithDividends(
|
|
candles: []const Candle,
|
|
dividends: []const Dividend,
|
|
today: Date,
|
|
) TrailingReturns {
|
|
if (candles.len == 0) return .{};
|
|
|
|
const month_end = today.lastDayOfPriorMonth();
|
|
|
|
return .{
|
|
.one_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(1), month_end),
|
|
.three_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(3), month_end),
|
|
.five_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(5), month_end),
|
|
.ten_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(10), month_end),
|
|
};
|
|
}
|
|
|
|
const SearchDirection = enum { forward, backward };
|
|
|
|
/// Maximum calendar days a snapped candle can be from the target date.
|
|
/// Covers weekends + holidays (e.g., Christmas week). Beyond this, the data
|
|
/// is likely missing and the result would be misleading.
|
|
const max_snap_days: i32 = 10;
|
|
|
|
fn findNearestCandle(candles: []const Candle, target: Date, direction: SearchDirection) ?Candle {
|
|
if (candles.len == 0) return null;
|
|
|
|
// Binary search: lo = first index where candles[lo].date >= target
|
|
var lo: usize = 0;
|
|
var hi: usize = candles.len;
|
|
while (lo < hi) {
|
|
const mid = lo + (hi - lo) / 2;
|
|
if (candles[mid].date.lessThan(target)) {
|
|
lo = mid + 1;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
|
|
const candidate = switch (direction) {
|
|
// First candle on or after target
|
|
.forward => if (lo < candles.len) candles[lo] else return null,
|
|
// Last candle on or before target
|
|
.backward => if (lo < candles.len and candles[lo].date.eql(target))
|
|
candles[lo]
|
|
else if (lo > 0)
|
|
candles[lo - 1]
|
|
else
|
|
return null,
|
|
};
|
|
|
|
// Reject if the snap distance exceeds tolerance
|
|
const gap = candidate.date.days - target.days;
|
|
if (gap > max_snap_days or gap < -max_snap_days) return null;
|
|
|
|
return candidate;
|
|
}
|
|
|
|
/// Format a return value as a percentage string (e.g., "12.34%")
|
|
pub fn formatReturn(buf: []u8, value: f64) []const u8 {
|
|
return std.fmt.bufPrint(buf, "{d:.2}%", .{value * 100.0}) catch "??%";
|
|
}
|
|
|
|
test "total return simple" {
|
|
const candles = [_]Candle{
|
|
.{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100, .adj_close = 100, .volume = 1000 },
|
|
.{ .date = Date.fromYmd(2024, 6, 28), .open = 110, .high = 111, .low = 109, .close = 110, .adj_close = 110, .volume = 1000 },
|
|
.{ .date = Date.fromYmd(2024, 12, 31), .open = 120, .high = 121, .low = 119, .close = 120, .adj_close = 120, .volume = 1000 },
|
|
};
|
|
const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
|
|
try std.testing.expect(result != null);
|
|
// 120/100 - 1 = 0.20
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.?.total_return, 0.001);
|
|
}
|
|
|
|
test "total return with dividends -- single dividend" {
|
|
// Stock at $100, pays $2 dividend, price stays $100.
|
|
// Without reinvestment: 0% return.
|
|
// With reinvestment: $2/$100 = 0.02 extra shares -> 1.02 * $100 / $100 - 1 = 2%
|
|
const candles = [_]Candle{
|
|
makeCandle(Date.fromYmd(2024, 1, 2), 100),
|
|
makeCandle(Date.fromYmd(2024, 3, 15), 100),
|
|
makeCandle(Date.fromYmd(2024, 12, 31), 100),
|
|
};
|
|
const divs = [_]Dividend{
|
|
.{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 2.0 },
|
|
};
|
|
const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
|
|
try std.testing.expect(result != null);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.02), result.?.total_return, 0.0001);
|
|
}
|
|
|
|
test "total return with dividends -- quarterly dividends" {
|
|
// Stock at $100 all year, pays $1 quarterly. Each $1 reinvested at $100 = 0.01 shares.
|
|
// After Q1: 1.01 shares
|
|
// After Q2: 1.01 + 1.01*1/100 = 1.01 * 1.01 = 1.0201
|
|
// After Q3: 1.0201 * 1.01 = 1.030301
|
|
// After Q4: 1.030301 * 1.01 = 1.04060401
|
|
// Total return: 4.06%
|
|
const candles = [_]Candle{
|
|
makeCandle(Date.fromYmd(2024, 1, 2), 100),
|
|
makeCandle(Date.fromYmd(2024, 3, 15), 100),
|
|
makeCandle(Date.fromYmd(2024, 6, 14), 100),
|
|
makeCandle(Date.fromYmd(2024, 9, 13), 100),
|
|
makeCandle(Date.fromYmd(2024, 12, 13), 100),
|
|
makeCandle(Date.fromYmd(2024, 12, 31), 100),
|
|
};
|
|
const divs = [_]Dividend{
|
|
.{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 1.0 },
|
|
.{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 1.0 },
|
|
.{ .ex_date = Date.fromYmd(2024, 9, 13), .amount = 1.0 },
|
|
.{ .ex_date = Date.fromYmd(2024, 12, 13), .amount = 1.0 },
|
|
};
|
|
const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
|
|
try std.testing.expect(result != null);
|
|
// (1.01)^4 - 1 = 0.04060401
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.04060401), result.?.total_return, 0.0001);
|
|
}
|
|
|
|
test "total return with dividends -- price growth plus dividends" {
|
|
// Start $100, end $120 (20% price return).
|
|
// One $3 dividend at mid-year when price is $110.
|
|
// Shares: 1 + 3/110 = 1.027273
|
|
// Final value: 1.027273 * 120 = 123.2727
|
|
// Total return: 123.2727 / 100 - 1 = 23.27%
|
|
const candles = [_]Candle{
|
|
makeCandle(Date.fromYmd(2024, 1, 2), 100),
|
|
makeCandle(Date.fromYmd(2024, 6, 14), 110),
|
|
makeCandle(Date.fromYmd(2024, 12, 31), 120),
|
|
};
|
|
const divs = [_]Dividend{
|
|
.{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 3.0 },
|
|
};
|
|
const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
|
|
try std.testing.expect(result != null);
|
|
const expected = (1.0 + 3.0 / 110.0) * 120.0 / 100.0 - 1.0; // 0.23272727...
|
|
try std.testing.expectApproxEqAbs(expected, result.?.total_return, 0.0001);
|
|
}
|
|
|
|
test "annualized return -- 3 year period" {
|
|
// 3 years: $100 -> $150. Total return = 50%.
|
|
// Annualized = (1.50)^(1/3) - 1 = 14.47%
|
|
const candles = [_]Candle{
|
|
makeCandle(Date.fromYmd(2021, 1, 4), 100),
|
|
makeCandle(Date.fromYmd(2024, 1, 2), 150),
|
|
};
|
|
const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2021, 1, 1), Date.fromYmd(2024, 1, 3));
|
|
try std.testing.expect(result != null);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.50), result.?.total_return, 0.001);
|
|
const ann = result.?.annualized_return.?;
|
|
// (1.50)^(1/years) - 1, years ~ 3.0 (via 365.25)
|
|
const years = Date.yearsBetween(Date.fromYmd(2021, 1, 4), Date.fromYmd(2024, 1, 2));
|
|
const expected_ann = std.math.pow(f64, 1.50, 1.0 / years) - 1.0;
|
|
try std.testing.expectApproxEqAbs(expected_ann, ann, 0.0001);
|
|
}
|
|
|
|
test "findNearestCandle -- 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),
|
|
};
|
|
// Forward exact
|
|
const fwd = findNearestCandle(&candles, Date.fromYmd(2024, 1, 3), .forward).?;
|
|
try std.testing.expect(fwd.date.eql(Date.fromYmd(2024, 1, 3)));
|
|
// Backward exact
|
|
const bwd = findNearestCandle(&candles, Date.fromYmd(2024, 1, 3), .backward).?;
|
|
try std.testing.expect(bwd.date.eql(Date.fromYmd(2024, 1, 3)));
|
|
}
|
|
|
|
test "findNearestCandle -- weekend snap" {
|
|
// Jan 4 2025 is Saturday, Jan 5 is Sunday
|
|
const candles = [_]Candle{
|
|
makeCandle(Date.fromYmd(2025, 1, 3), 100), // Friday
|
|
makeCandle(Date.fromYmd(2025, 1, 6), 101), // Monday
|
|
};
|
|
// Forward from Saturday -> Monday
|
|
const fwd = findNearestCandle(&candles, Date.fromYmd(2025, 1, 4), .forward).?;
|
|
try std.testing.expect(fwd.date.eql(Date.fromYmd(2025, 1, 6)));
|
|
// Backward from Saturday -> Friday
|
|
const bwd = findNearestCandle(&candles, Date.fromYmd(2025, 1, 4), .backward).?;
|
|
try std.testing.expect(bwd.date.eql(Date.fromYmd(2025, 1, 3)));
|
|
}
|
|
|
|
test "month-end trailing returns -- date windowing" {
|
|
// Verify month-end logic uses correct reference dates.
|
|
// "Today" = 2026-02-15, prior month end = 2026-01-31
|
|
// 1yr window: 2025-01-31 to 2026-01-31
|
|
const candles = [_]Candle{
|
|
makeCandle(Date.fromYmd(2025, 1, 31), 100), // Jan 31 2025 is Friday
|
|
makeCandle(Date.fromYmd(2025, 7, 1), 110),
|
|
makeCandle(Date.fromYmd(2026, 1, 30), 120), // Jan 31 is Sat, trading day is 30th
|
|
makeCandle(Date.fromYmd(2026, 2, 14), 125),
|
|
};
|
|
const today = Date.fromYmd(2026, 2, 15);
|
|
const ret = trailingReturnsMonthEnd(&candles, today);
|
|
// Month-end = Jan 31 2026. backward snap -> Jan 30.
|
|
// Start = Jan 31 2025 (exact match, backward snap). End = Jan 30 2026.
|
|
// Return = 120/100 - 1 = 20%
|
|
try std.testing.expect(ret.one_year != null);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.20), ret.one_year.?.total_return, 0.001);
|
|
}
|
|
|
|
test "month-end trailing returns -- weekend start snaps backward" {
|
|
// When the start month-end falls on a weekend, it should snap BACKWARD
|
|
// to the last trading day (Friday), not forward to Monday.
|
|
// This matches Morningstar's "last business day of the month" convention.
|
|
const candles = [_]Candle{
|
|
makeCandle(Date.fromYmd(2016, 1, 29), 100), // Friday (last biz day of Jan 2016)
|
|
makeCandle(Date.fromYmd(2016, 2, 1), 95), // Monday (NOT what we want)
|
|
makeCandle(Date.fromYmd(2026, 1, 30), 240), // End: Friday (last biz day of Jan 2026)
|
|
};
|
|
// Jan 31 2016 is Sunday. Backward snap -> Jan 29 (Friday).
|
|
// Jan 31 2026 is Saturday. Backward snap -> Jan 30 (Friday).
|
|
// Return = 240/100 - 1 = 140%
|
|
const today = Date.fromYmd(2026, 2, 15);
|
|
const ret = trailingReturnsMonthEnd(&candles, today);
|
|
try std.testing.expect(ret.ten_year != null);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 1.40), ret.ten_year.?.total_return, 0.001);
|
|
// Verify start date is Jan 29 (Friday), not Feb 1 (Monday)
|
|
try std.testing.expect(ret.ten_year.?.from.eql(Date.fromYmd(2016, 1, 29)));
|
|
}
|
|
|
|
test "dividends outside window are excluded" {
|
|
const candles = [_]Candle{
|
|
makeCandle(Date.fromYmd(2024, 1, 2), 100),
|
|
makeCandle(Date.fromYmd(2024, 6, 14), 100),
|
|
makeCandle(Date.fromYmd(2024, 12, 31), 100),
|
|
};
|
|
const divs = [_]Dividend{
|
|
.{ .ex_date = Date.fromYmd(2023, 12, 15), .amount = 5.0 }, // before window
|
|
.{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 2.0 }, // inside
|
|
.{ .ex_date = Date.fromYmd(2025, 3, 15), .amount = 5.0 }, // after window
|
|
};
|
|
const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
|
|
try std.testing.expect(result != null);
|
|
// Only the $2 mid-year dividend counts: 2/100 = 2%
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.02), result.?.total_return, 0.0001);
|
|
}
|
|
|
|
test "zero price candle returns null" {
|
|
const candles = [_]Candle{
|
|
makeCandle(Date.fromYmd(2024, 1, 2), 0),
|
|
makeCandle(Date.fromYmd(2024, 12, 31), 100),
|
|
};
|
|
const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
|
|
try std.testing.expect(result == null);
|
|
}
|
|
|
|
test "empty candles returns null" {
|
|
const candles = [_]Candle{};
|
|
const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
|
|
try std.testing.expect(result == null);
|
|
}
|
|
|
|
fn makeCandle(date: Date, price: f64) Candle {
|
|
return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 };
|
|
}
|
|
|
|
// Morningstar reference data, captured 2026-02-24.
|
|
//
|
|
// AMZN Trailing Returns (as-of-date, from morningstar.com/stocks/xnas/amzn/trailing-returns):
|
|
// Day end 2026-02-24: 1yr=-1.95% 3yr=30.66% 5yr=5.71% 10yr=22.37%
|
|
// AMZN has no dividends, so price return = total return.
|
|
//
|
|
// VTI Trailing Returns (as-of-date, from morningstar.com/etfs/arcx/vti/trailing-returns):
|
|
// Day end 2026-02-24: 1yr=16.62% 3yr=21.01% 5yr=12.03% 10yr=15.10% (price)
|
|
//
|
|
// VTI Performance (month-end, from morningstar.com/etfs/arcx/vti/performance):
|
|
// Month-end Jan 31: 10yr total=15.10% 3yr total=20.20% (NAV ~20.24%)
|
|
|
|
test "as-of-date trailing returns -- AMZN vs Morningstar" {
|
|
// Real AMZN split-adjusted closing prices from Twelve Data.
|
|
// AMZN pays no dividends, so adj_close == close.
|
|
const candles = [_]Candle{
|
|
makeCandle(Date.fromYmd(2016, 2, 24), 27.702), // 10yr start
|
|
makeCandle(Date.fromYmd(2021, 2, 24), 157.9765), // 5yr start
|
|
makeCandle(Date.fromYmd(2023, 2, 24), 93.50), // 3yr start
|
|
makeCandle(Date.fromYmd(2025, 2, 24), 212.71), // 1yr start
|
|
makeCandle(Date.fromYmd(2026, 2, 24), 208.56), // end (latest close)
|
|
};
|
|
|
|
const ret = trailingReturns(&candles);
|
|
|
|
// 1yr: 208.56 / 212.71 - 1 = -1.95%
|
|
try std.testing.expect(ret.one_year != null);
|
|
try std.testing.expectApproxEqAbs(@as(f64, -0.0195), ret.one_year.?.total_return, 0.001);
|
|
|
|
// 3yr: annualized. Morningstar shows 30.66%.
|
|
try std.testing.expect(ret.three_year != null);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.3066), ret.three_year.?.annualized_return.?, 0.002);
|
|
|
|
// 5yr: annualized. Morningstar shows 5.71%.
|
|
try std.testing.expect(ret.five_year != null);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0571), ret.five_year.?.annualized_return.?, 0.002);
|
|
|
|
// 10yr: annualized. Morningstar shows 22.37%.
|
|
try std.testing.expect(ret.ten_year != null);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.2237), ret.ten_year.?.annualized_return.?, 0.002);
|
|
}
|
|
|
|
test "as-of-date vs month-end -- different results from same data" {
|
|
// Demonstrates that as-of-date and month-end give different results
|
|
// when the latest close differs significantly from the month-end close.
|
|
//
|
|
// "Today" = 2026-02-25, month-end = Jan 31 2026
|
|
// As-of end = Feb 24 (latest candle), month-end = Jan 30 (snap from Jan 31 Sat)
|
|
const candles = [_]Candle{
|
|
makeCandle(Date.fromYmd(2025, 1, 31), 100), // month-end 1yr start (Friday)
|
|
makeCandle(Date.fromYmd(2025, 2, 24), 100), // as-of 1yr start
|
|
makeCandle(Date.fromYmd(2025, 7, 1), 110),
|
|
makeCandle(Date.fromYmd(2026, 1, 30), 115), // month-end end (Friday, Jan 31 is Sat)
|
|
makeCandle(Date.fromYmd(2026, 2, 24), 120), // as-of end (latest)
|
|
};
|
|
|
|
// As-of-date: end=Feb 24 ($120), start=Feb 24 prior year ($100) → 20%
|
|
const asof = trailingReturns(&candles);
|
|
try std.testing.expect(asof.one_year != null);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.20), asof.one_year.?.total_return, 0.001);
|
|
|
|
// Month-end: end=Jan 30 ($115), start=Jan 31 ($100) → 15%
|
|
const me = trailingReturnsMonthEnd(&candles, Date.fromYmd(2026, 2, 25));
|
|
try std.testing.expect(me.one_year != null);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.15), me.one_year.?.total_return, 0.001);
|
|
}
|